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