Skip to main content

omni_dev/datadog/
monitors_api.rs

1//! Datadog Monitors API wrapper.
2//!
3//! Exposes a thin façade over [`DatadogClient`] for the read-only monitor
4//! endpoints needed by the CLI: list, get, and search. List and search
5//! auto-paginate when called with `limit == 0`, capped at [`HARD_CAP`]
6//! per the Phase 1 decisions on [#619].
7//!
8//! [#619]: https://github.com/rust-works/omni-dev/issues/619
9
10use anyhow::Result;
11use url::Url;
12
13use crate::datadog::client::DatadogClient;
14use crate::datadog::types::{Monitor, MonitorSearchResult};
15
16/// Per-call upper bound on the number of monitors returned, even when
17/// the caller passes `limit = 0` (fetch-all).
18pub const HARD_CAP: usize = 10_000;
19
20/// Default page size used when paginating list / search responses.
21///
22/// Datadog's `/api/v1/monitor` defaults to 100 and accepts up to 1000;
23/// `/api/v1/monitor/search` defaults to 30 (which we keep so search
24/// requests stay well within Datadog's per-query budget).
25pub const LIST_PAGE_SIZE: usize = 100;
26const SEARCH_PAGE_SIZE: usize = 30;
27
28/// Filters accepted by `GET /api/v1/monitor`.
29///
30/// Each field is optional: the URL builder appends a query parameter
31/// only when the field is `Some(_)`. `tags` and `monitor_tags` are
32/// passed through verbatim — Datadog expects a comma-separated
33/// `key:value` string.
34#[derive(Debug, Default, Clone)]
35pub struct MonitorListFilter {
36    /// Substring match on the monitor name.
37    pub name: Option<String>,
38    /// Comma-separated `key:value` tags applied to the monitor.
39    pub tags: Option<String>,
40    /// Comma-separated `key:value` tags applied via `monitor_tags`.
41    pub monitor_tags: Option<String>,
42}
43
44/// Monitors API façade.
45#[derive(Debug)]
46pub struct MonitorsApi<'a> {
47    client: &'a DatadogClient,
48}
49
50impl<'a> MonitorsApi<'a> {
51    /// Wraps an existing [`DatadogClient`] for monitor operations.
52    #[must_use]
53    pub fn new(client: &'a DatadogClient) -> Self {
54        Self { client }
55    }
56
57    /// Lists monitors matching `filter`, auto-paginating as needed.
58    ///
59    /// `limit == 0` means "fetch every monitor up to [`HARD_CAP`]".
60    /// Any non-zero `limit` is upper-bounded by [`HARD_CAP`] to keep a
61    /// single CLI invocation from issuing more than 10k items' worth of
62    /// requests.
63    pub async fn list(&self, filter: &MonitorListFilter, limit: usize) -> Result<Vec<Monitor>> {
64        let cap = effective_cap(limit);
65        let mut out: Vec<Monitor> = Vec::new();
66        let mut page: u32 = 0;
67        loop {
68            let remaining = cap - out.len();
69            let page_size = remaining.min(LIST_PAGE_SIZE);
70            let url = build_list_url(self.client.base_url(), filter, page, page_size)?;
71            let batch: Vec<Monitor> = self
72                .client
73                .get_parsed(url.as_str(), "Failed to parse /api/v1/monitor response")
74                .await?;
75            let exhausted = batch.len() < page_size;
76            out.extend(batch);
77            if out.len() >= cap || exhausted {
78                break;
79            }
80            page += 1;
81        }
82        out.truncate(cap);
83        Ok(out)
84    }
85
86    /// Fetches a single monitor by id.
87    pub async fn get(&self, id: i64) -> Result<Monitor> {
88        let url = build_get_url(self.client.base_url(), id)?;
89        self.client
90            .get_parsed(
91                url.as_str(),
92                "Failed to parse /api/v1/monitor/<id> response",
93            )
94            .await
95    }
96
97    /// Searches monitors against the free-text / faceted query string,
98    /// auto-paginating as needed.
99    ///
100    /// `limit == 0` means "fetch every match up to [`HARD_CAP`]". The
101    /// returned envelope keeps `counts` and `metadata` from the first
102    /// successful page; only the `monitors` list is concatenated across
103    /// pages.
104    pub async fn search(&self, query: &str, limit: usize) -> Result<MonitorSearchResult> {
105        let cap = effective_cap(limit);
106        let mut acc: Option<MonitorSearchResult> = None;
107        let mut page: u32 = 0;
108        loop {
109            let collected = acc.as_ref().map_or(0, |r| r.monitors.len());
110            let remaining = cap - collected;
111            let per_page = remaining.min(SEARCH_PAGE_SIZE);
112            let url = build_search_url(self.client.base_url(), query, page, per_page)?;
113            let batch: MonitorSearchResult = self
114                .client
115                .get_parsed(
116                    url.as_str(),
117                    "Failed to parse /api/v1/monitor/search response",
118                )
119                .await?;
120            let batch_len = batch.monitors.len();
121            let page_count = batch.metadata.as_ref().and_then(|m| m.page_count);
122            let exhausted_by_size = batch_len < per_page;
123            let exhausted_by_metadata = page_count.is_some_and(|pc| i64::from(page) + 1 >= pc);
124
125            match acc.as_mut() {
126                Some(existing) => existing.monitors.extend(batch.monitors),
127                None => acc = Some(batch),
128            }
129
130            let collected = acc.as_ref().map_or(0, |r| r.monitors.len());
131            if collected >= cap || exhausted_by_size || exhausted_by_metadata {
132                break;
133            }
134            page += 1;
135        }
136        let mut result = acc.unwrap_or_default();
137        result.monitors.truncate(cap);
138        Ok(result)
139    }
140}
141
142/// Clamps a caller-supplied limit to [`HARD_CAP`], treating `0` as
143/// "fetch as many as the cap allows".
144fn effective_cap(limit: usize) -> usize {
145    if limit == 0 {
146        HARD_CAP
147    } else {
148        limit.min(HARD_CAP)
149    }
150}
151
152/// Builds `{base_url}/api/v1/monitor?{filters}&page=…&page_size=…`.
153fn build_list_url(
154    base_url: &str,
155    filter: &MonitorListFilter,
156    page: u32,
157    page_size: usize,
158) -> Result<Url> {
159    let mut url = DatadogClient::api_url(base_url, "/api/v1/monitor")?;
160    {
161        let mut q = url.query_pairs_mut();
162        if let Some(name) = filter.name.as_deref() {
163            q.append_pair("name", name);
164        }
165        if let Some(tags) = filter.tags.as_deref() {
166            q.append_pair("tags", tags);
167        }
168        if let Some(monitor_tags) = filter.monitor_tags.as_deref() {
169            q.append_pair("monitor_tags", monitor_tags);
170        }
171        q.append_pair("page", &page.to_string());
172        q.append_pair("page_size", &page_size.to_string());
173    }
174    Ok(url)
175}
176
177/// Builds `{base_url}/api/v1/monitor/{id}`.
178fn build_get_url(base_url: &str, id: i64) -> Result<Url> {
179    DatadogClient::api_url(base_url, &format!("/api/v1/monitor/{id}"))
180}
181
182/// Builds `{base_url}/api/v1/monitor/search?query=…&page=…&per_page=…`.
183fn build_search_url(base_url: &str, query: &str, page: u32, per_page: usize) -> Result<Url> {
184    let mut url = DatadogClient::api_url(base_url, "/api/v1/monitor/search")?;
185    url.query_pairs_mut()
186        .append_pair("query", query)
187        .append_pair("page", &page.to_string())
188        .append_pair("per_page", &per_page.to_string());
189    Ok(url)
190}
191
192#[cfg(test)]
193#[allow(clippy::unwrap_used, clippy::expect_used)]
194mod tests {
195    use super::*;
196
197    // ── effective_cap ──────────────────────────────────────────────
198
199    #[test]
200    fn effective_cap_zero_means_hard_cap() {
201        assert_eq!(effective_cap(0), HARD_CAP);
202    }
203
204    #[test]
205    fn effective_cap_clamps_to_hard_cap() {
206        assert_eq!(effective_cap(HARD_CAP + 5), HARD_CAP);
207    }
208
209    #[test]
210    fn effective_cap_passes_through_small_limits() {
211        assert_eq!(effective_cap(42), 42);
212    }
213
214    // ── URL builders ───────────────────────────────────────────────
215
216    #[test]
217    fn build_list_url_appends_only_provided_filters() {
218        let filter = MonitorListFilter {
219            name: Some("cpu".into()),
220            tags: None,
221            monitor_tags: None,
222        };
223        let url = build_list_url("https://api.datadoghq.com", &filter, 0, 100).unwrap();
224        let qs = url.query().unwrap();
225        assert!(qs.contains("name=cpu"));
226        assert!(qs.contains("page=0"));
227        assert!(qs.contains("page_size=100"));
228        assert!(!qs.contains("tags="));
229        assert!(!qs.contains("monitor_tags="));
230    }
231
232    #[test]
233    fn build_list_url_encodes_tags_and_monitor_tags() {
234        let filter = MonitorListFilter {
235            name: None,
236            tags: Some("team:sre,env:prod".into()),
237            monitor_tags: Some("severity:high".into()),
238        };
239        let url = build_list_url("https://api.datadoghq.com", &filter, 2, 50).unwrap();
240        let qs = url.query().unwrap();
241        // `:` and `,` get percent-encoded by url's form-encoder.
242        assert!(qs.contains("tags=team%3Asre%2Cenv%3Aprod"));
243        assert!(qs.contains("monitor_tags=severity%3Ahigh"));
244        assert!(qs.contains("page=2"));
245        assert!(qs.contains("page_size=50"));
246    }
247
248    #[test]
249    fn build_list_url_rejects_invalid_base() {
250        let err = build_list_url("not a url", &MonitorListFilter::default(), 0, 100).unwrap_err();
251        assert!(err.to_string().contains("Invalid Datadog base URL"));
252    }
253
254    #[test]
255    fn build_get_url_includes_id_path_segment() {
256        let url = build_get_url("https://api.datadoghq.com", 12345).unwrap();
257        assert_eq!(url.path(), "/api/v1/monitor/12345");
258    }
259
260    #[test]
261    fn build_get_url_rejects_invalid_base() {
262        let err = build_get_url("not a url", 1).unwrap_err();
263        assert!(err.to_string().contains("Invalid Datadog base URL"));
264    }
265
266    #[test]
267    fn build_search_url_encodes_query() {
268        let url = build_search_url(
269            "https://api.datadoghq.com",
270            "status:alert AND env:prod",
271            0,
272            30,
273        )
274        .unwrap();
275        let qs = url.query().unwrap();
276        assert!(qs.contains("query=status%3Aalert+AND+env%3Aprod"));
277        assert!(qs.contains("page=0"));
278        assert!(qs.contains("per_page=30"));
279    }
280
281    #[test]
282    fn build_search_url_rejects_invalid_base() {
283        let err = build_search_url("not a url", "q", 0, 30).unwrap_err();
284        assert!(err.to_string().contains("Invalid Datadog base URL"));
285    }
286
287    // ── fixtures ───────────────────────────────────────────────────
288
289    fn monitor_json(id: i64, name: &str) -> serde_json::Value {
290        serde_json::json!({
291            "id": id,
292            "name": name,
293            "type": "metric alert",
294            "query": "avg(last_5m):avg:system.cpu.user{*} > 90",
295            "tags": ["team:sre"],
296            "overall_state": "OK"
297        })
298    }
299
300    fn search_page(items: usize, page: i64, page_count: i64, total: i64) -> serde_json::Value {
301        let monitors: Vec<serde_json::Value> = (0..items)
302            .map(|i| {
303                serde_json::json!({
304                    "id": (page * 100) + i as i64,
305                    "name": format!("Monitor {i}"),
306                    "status": "ALERT",
307                    "tags": ["env:prod"]
308                })
309            })
310            .collect();
311        serde_json::json!({
312            "monitors": monitors,
313            "counts": {},
314            "metadata": {
315                "page": page,
316                "per_page": items as i64,
317                "page_count": page_count,
318                "total_count": total
319            }
320        })
321    }
322
323    // ── list happy path / pagination ───────────────────────────────
324
325    #[tokio::test]
326    async fn list_single_page_returns_parsed_monitors() {
327        let server = wiremock::MockServer::start().await;
328        wiremock::Mock::given(wiremock::matchers::method("GET"))
329            .and(wiremock::matchers::path("/api/v1/monitor"))
330            .and(wiremock::matchers::query_param("name", "cpu"))
331            .and(wiremock::matchers::query_param("page", "0"))
332            .and(wiremock::matchers::query_param("page_size", "5"))
333            .and(wiremock::matchers::header("DD-API-KEY", "api"))
334            .and(wiremock::matchers::header("DD-APPLICATION-KEY", "app"))
335            .respond_with(
336                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
337                    monitor_json(1, "Disk full"),
338                    monitor_json(2, "CPU high")
339                ])),
340            )
341            .expect(1)
342            .mount(&server)
343            .await;
344
345        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
346        let filter = MonitorListFilter {
347            name: Some("cpu".into()),
348            tags: None,
349            monitor_tags: None,
350        };
351        let monitors = MonitorsApi::new(&client).list(&filter, 5).await.unwrap();
352        assert_eq!(monitors.len(), 2);
353        assert_eq!(monitors[0].id, 1);
354        assert_eq!(monitors[1].name, "CPU high");
355    }
356
357    #[tokio::test]
358    async fn list_auto_paginates_until_short_page() {
359        // Three pages: [page 0]=100 items, [page 1]=100 items, [page 2]=37 items.
360        let server = wiremock::MockServer::start().await;
361        for page in 0..2 {
362            let body: Vec<serde_json::Value> = (0..LIST_PAGE_SIZE as i64)
363                .map(|i| monitor_json(page * 100 + i, "m"))
364                .collect();
365            wiremock::Mock::given(wiremock::matchers::method("GET"))
366                .and(wiremock::matchers::path("/api/v1/monitor"))
367                .and(wiremock::matchers::query_param("page", page.to_string()))
368                .and(wiremock::matchers::query_param(
369                    "page_size",
370                    LIST_PAGE_SIZE.to_string(),
371                ))
372                .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
373                .expect(1)
374                .mount(&server)
375                .await;
376        }
377        let last_page: Vec<serde_json::Value> =
378            (0..37_i64).map(|i| monitor_json(200 + i, "m")).collect();
379        wiremock::Mock::given(wiremock::matchers::method("GET"))
380            .and(wiremock::matchers::path("/api/v1/monitor"))
381            .and(wiremock::matchers::query_param("page", "2"))
382            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(last_page))
383            .expect(1)
384            .mount(&server)
385            .await;
386
387        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
388        let monitors = MonitorsApi::new(&client)
389            .list(&MonitorListFilter::default(), 0)
390            .await
391            .unwrap();
392        assert_eq!(monitors.len(), LIST_PAGE_SIZE * 2 + 37);
393        // First and last ids check ordering preserved across pages.
394        assert_eq!(monitors[0].id, 0);
395        assert_eq!(monitors.last().unwrap().id, 236);
396    }
397
398    #[tokio::test]
399    async fn list_caps_explicit_limit_at_hard_cap() {
400        // The user asked for more than HARD_CAP — the API never sees a
401        // request with page_size > LIST_PAGE_SIZE because we clamp first,
402        // and we stop after HARD_CAP items.
403        let server = wiremock::MockServer::start().await;
404        let body: Vec<serde_json::Value> = (0..LIST_PAGE_SIZE as i64)
405            .map(|i| monitor_json(i, "m"))
406            .collect();
407        // Mount a single mock that responds to *any* page request with a
408        // full page; the loop will stop when it hits HARD_CAP.
409        wiremock::Mock::given(wiremock::matchers::method("GET"))
410            .and(wiremock::matchers::path("/api/v1/monitor"))
411            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
412            .mount(&server)
413            .await;
414
415        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
416        let monitors = MonitorsApi::new(&client)
417            .list(&MonitorListFilter::default(), HARD_CAP + 50)
418            .await
419            .unwrap();
420        assert_eq!(monitors.len(), HARD_CAP);
421    }
422
423    #[tokio::test]
424    async fn list_stops_when_explicit_limit_reached_within_first_page() {
425        let server = wiremock::MockServer::start().await;
426        // Limit 3 → page_size becomes 3; the API returns exactly 3 (a
427        // "short page" by user's request), so we stop without page 1.
428        let body: Vec<serde_json::Value> = (0..3_i64).map(|i| monitor_json(i, "m")).collect();
429        wiremock::Mock::given(wiremock::matchers::method("GET"))
430            .and(wiremock::matchers::path("/api/v1/monitor"))
431            .and(wiremock::matchers::query_param("page", "0"))
432            .and(wiremock::matchers::query_param("page_size", "3"))
433            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
434            .expect(1)
435            .mount(&server)
436            .await;
437
438        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
439        let monitors = MonitorsApi::new(&client)
440            .list(&MonitorListFilter::default(), 3)
441            .await
442            .unwrap();
443        assert_eq!(monitors.len(), 3);
444    }
445
446    #[tokio::test]
447    async fn list_propagates_api_errors() {
448        let server = wiremock::MockServer::start().await;
449        wiremock::Mock::given(wiremock::matchers::method("GET"))
450            .and(wiremock::matchers::path("/api/v1/monitor"))
451            .respond_with(
452                wiremock::ResponseTemplate::new(403).set_body_string(r#"{"errors":["nope"]}"#),
453            )
454            .mount(&server)
455            .await;
456
457        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
458        let err = MonitorsApi::new(&client)
459            .list(&MonitorListFilter::default(), 5)
460            .await
461            .unwrap_err();
462        let msg = err.to_string();
463        assert!(msg.contains("403"));
464        assert!(msg.contains("nope"));
465    }
466
467    #[tokio::test]
468    async fn list_propagates_invalid_base_url_error() {
469        let client = DatadogClient::new("not a url", "api", "app").unwrap();
470        let err = MonitorsApi::new(&client)
471            .list(&MonitorListFilter::default(), 5)
472            .await
473            .unwrap_err();
474        assert!(err.to_string().contains("Invalid Datadog base URL"));
475    }
476
477    #[tokio::test]
478    async fn list_propagates_network_errors() {
479        // Point at a port that refuses connection; `get_json` surfaces the
480        // reqwest send failure via anyhow context.
481        let client = DatadogClient::new("http://127.0.0.1:1", "api", "app").unwrap();
482        let err = MonitorsApi::new(&client)
483            .list(&MonitorListFilter::default(), 5)
484            .await
485            .unwrap_err();
486        assert!(err.to_string().contains("Failed to send"));
487    }
488
489    #[tokio::test]
490    async fn list_errors_on_malformed_response() {
491        let server = wiremock::MockServer::start().await;
492        wiremock::Mock::given(wiremock::matchers::method("GET"))
493            .and(wiremock::matchers::path("/api/v1/monitor"))
494            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
495            .mount(&server)
496            .await;
497
498        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
499        let err = MonitorsApi::new(&client)
500            .list(&MonitorListFilter::default(), 5)
501            .await
502            .unwrap_err();
503        assert!(err.to_string().contains("Failed to parse"));
504    }
505
506    // ── get happy path ─────────────────────────────────────────────
507
508    #[tokio::test]
509    async fn get_returns_parsed_monitor() {
510        let server = wiremock::MockServer::start().await;
511        wiremock::Mock::given(wiremock::matchers::method("GET"))
512            .and(wiremock::matchers::path("/api/v1/monitor/12345"))
513            .and(wiremock::matchers::header("DD-API-KEY", "api"))
514            .respond_with(
515                wiremock::ResponseTemplate::new(200).set_body_json(monitor_json(12345, "CPU high")),
516            )
517            .expect(1)
518            .mount(&server)
519            .await;
520
521        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
522        let m = MonitorsApi::new(&client).get(12345).await.unwrap();
523        assert_eq!(m.id, 12345);
524        assert_eq!(m.name, "CPU high");
525    }
526
527    #[tokio::test]
528    async fn get_propagates_404() {
529        let server = wiremock::MockServer::start().await;
530        wiremock::Mock::given(wiremock::matchers::method("GET"))
531            .and(wiremock::matchers::path("/api/v1/monitor/9"))
532            .respond_with(
533                wiremock::ResponseTemplate::new(404).set_body_string(r#"{"errors":["Not found"]}"#),
534            )
535            .mount(&server)
536            .await;
537
538        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
539        let err = MonitorsApi::new(&client).get(9).await.unwrap_err();
540        assert!(err.to_string().contains("404"));
541        assert!(err.to_string().contains("Not found"));
542    }
543
544    #[tokio::test]
545    async fn get_propagates_invalid_base_url_error() {
546        let client = DatadogClient::new("not a url", "api", "app").unwrap();
547        let err = MonitorsApi::new(&client).get(1).await.unwrap_err();
548        assert!(err.to_string().contains("Invalid Datadog base URL"));
549    }
550
551    #[tokio::test]
552    async fn get_propagates_network_errors() {
553        let client = DatadogClient::new("http://127.0.0.1:1", "api", "app").unwrap();
554        let err = MonitorsApi::new(&client).get(1).await.unwrap_err();
555        assert!(err.to_string().contains("Failed to send"));
556    }
557
558    #[tokio::test]
559    async fn get_errors_on_malformed_response() {
560        let server = wiremock::MockServer::start().await;
561        wiremock::Mock::given(wiremock::matchers::method("GET"))
562            .and(wiremock::matchers::path("/api/v1/monitor/1"))
563            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
564            .mount(&server)
565            .await;
566
567        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
568        let err = MonitorsApi::new(&client).get(1).await.unwrap_err();
569        assert!(err.to_string().contains("Failed to parse"));
570    }
571
572    // ── search happy path / pagination ─────────────────────────────
573
574    #[tokio::test]
575    async fn search_single_page_returns_envelope() {
576        let server = wiremock::MockServer::start().await;
577        wiremock::Mock::given(wiremock::matchers::method("GET"))
578            .and(wiremock::matchers::path("/api/v1/monitor/search"))
579            .and(wiremock::matchers::query_param("query", "status:alert"))
580            .and(wiremock::matchers::query_param("page", "0"))
581            .and(wiremock::matchers::query_param("per_page", "30"))
582            .respond_with(
583                wiremock::ResponseTemplate::new(200).set_body_json(search_page(2, 0, 1, 2)),
584            )
585            .expect(1)
586            .mount(&server)
587            .await;
588
589        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
590        let result = MonitorsApi::new(&client)
591            .search("status:alert", 30)
592            .await
593            .unwrap();
594        assert_eq!(result.monitors.len(), 2);
595        assert_eq!(result.metadata.unwrap().total_count, Some(2));
596    }
597
598    #[tokio::test]
599    async fn search_auto_paginates_with_unbounded_limit() {
600        // page 0 + page 1 each return SEARCH_PAGE_SIZE items; metadata
601        // says page_count = 2 so we stop without issuing page 2.
602        let server = wiremock::MockServer::start().await;
603        for page in 0..2_i64 {
604            wiremock::Mock::given(wiremock::matchers::method("GET"))
605                .and(wiremock::matchers::path("/api/v1/monitor/search"))
606                .and(wiremock::matchers::query_param("page", page.to_string()))
607                .respond_with(
608                    wiremock::ResponseTemplate::new(200).set_body_json(search_page(
609                        SEARCH_PAGE_SIZE,
610                        page,
611                        2,
612                        60,
613                    )),
614                )
615                .expect(1)
616                .mount(&server)
617                .await;
618        }
619
620        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
621        let result = MonitorsApi::new(&client).search("q", 0).await.unwrap();
622        assert_eq!(result.monitors.len(), SEARCH_PAGE_SIZE * 2);
623    }
624
625    #[tokio::test]
626    async fn search_stops_on_short_page_when_metadata_missing() {
627        // page 0 returns fewer items than per_page → exhausted_by_size path.
628        let server = wiremock::MockServer::start().await;
629        wiremock::Mock::given(wiremock::matchers::method("GET"))
630            .and(wiremock::matchers::path("/api/v1/monitor/search"))
631            .and(wiremock::matchers::query_param("page", "0"))
632            .respond_with(
633                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
634                    "monitors": [
635                        { "id": 1_i64, "name": "Only" }
636                    ]
637                })),
638            )
639            .expect(1)
640            .mount(&server)
641            .await;
642
643        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
644        let result = MonitorsApi::new(&client).search("q", 0).await.unwrap();
645        assert_eq!(result.monitors.len(), 1);
646        assert!(result.metadata.is_none());
647    }
648
649    #[tokio::test]
650    async fn search_caps_at_explicit_limit_within_full_page() {
651        let server = wiremock::MockServer::start().await;
652        // Limit 5 → per_page becomes 5; if Datadog returns 5 we stop.
653        wiremock::Mock::given(wiremock::matchers::method("GET"))
654            .and(wiremock::matchers::path("/api/v1/monitor/search"))
655            .and(wiremock::matchers::query_param("page", "0"))
656            .and(wiremock::matchers::query_param("per_page", "5"))
657            .respond_with(
658                wiremock::ResponseTemplate::new(200).set_body_json(search_page(5, 0, 10, 100)),
659            )
660            .expect(1)
661            .mount(&server)
662            .await;
663
664        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
665        let result = MonitorsApi::new(&client).search("q", 5).await.unwrap();
666        assert_eq!(result.monitors.len(), 5);
667    }
668
669    #[tokio::test]
670    async fn search_propagates_api_errors() {
671        let server = wiremock::MockServer::start().await;
672        wiremock::Mock::given(wiremock::matchers::method("GET"))
673            .and(wiremock::matchers::path("/api/v1/monitor/search"))
674            .respond_with(
675                wiremock::ResponseTemplate::new(400).set_body_string(r#"{"errors":["bad query"]}"#),
676            )
677            .mount(&server)
678            .await;
679
680        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
681        let err = MonitorsApi::new(&client)
682            .search("???", 5)
683            .await
684            .unwrap_err();
685        let msg = err.to_string();
686        assert!(msg.contains("400"));
687        assert!(msg.contains("bad query"));
688    }
689
690    #[tokio::test]
691    async fn search_propagates_invalid_base_url_error() {
692        let client = DatadogClient::new("not a url", "api", "app").unwrap();
693        let err = MonitorsApi::new(&client).search("q", 5).await.unwrap_err();
694        assert!(err.to_string().contains("Invalid Datadog base URL"));
695    }
696
697    #[tokio::test]
698    async fn search_propagates_network_errors() {
699        let client = DatadogClient::new("http://127.0.0.1:1", "api", "app").unwrap();
700        let err = MonitorsApi::new(&client).search("q", 5).await.unwrap_err();
701        assert!(err.to_string().contains("Failed to send"));
702    }
703
704    #[tokio::test]
705    async fn search_errors_on_malformed_response() {
706        let server = wiremock::MockServer::start().await;
707        wiremock::Mock::given(wiremock::matchers::method("GET"))
708            .and(wiremock::matchers::path("/api/v1/monitor/search"))
709            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
710            .mount(&server)
711            .await;
712
713        let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
714        let err = MonitorsApi::new(&client).search("q", 5).await.unwrap_err();
715        assert!(err.to_string().contains("Failed to parse"));
716    }
717}