1use crate::config::ClientConfig;
4use crate::error::ClientError;
5use crate::types::*;
6use reqwest::header::{self, HeaderMap, HeaderValue};
7use tracing::debug;
8
9#[derive(Clone, Debug)]
11pub struct BaselineClient {
12 config: ClientConfig,
13 inner: reqwest::Client,
14}
15
16impl BaselineClient {
17 pub fn new(config: ClientConfig) -> Result<Self, ClientError> {
19 config.validate().map_err(ClientError::ValidationError)?;
20
21 let mut headers = HeaderMap::new();
22
23 if let Some(auth_val) = config.auth.header_value() {
24 let mut auth_value = HeaderValue::from_str(&auth_val)
25 .map_err(|e| ClientError::ValidationError(format!("Invalid auth header: {}", e)))?;
26 auth_value.set_sensitive(true);
27 headers.insert(header::AUTHORIZATION, auth_value);
28 }
29
30 let inner = reqwest::Client::builder()
31 .default_headers(headers)
32 .timeout(config.timeout)
33 .build()
34 .map_err(|e| ClientError::ConnectionError(e.to_string()))?;
35
36 Ok(Self { config, inner })
37 }
38
39 pub async fn upload_baseline(
41 &self,
42 project: &str,
43 request: &UploadBaselineRequest,
44 ) -> Result<UploadBaselineResponse, ClientError> {
45 self.execute_with_retry(|| {
46 let url = self.url(&format!("projects/{}/baselines", project));
47 debug!(url = %url, benchmark = %request.benchmark, "Uploading baseline");
48
49 let client = self.inner.clone();
50 let request = request.clone();
51 async move {
52 let response = client
53 .post(url)
54 .json(&request)
55 .send()
56 .await
57 .map_err(ClientError::RequestError)?;
58
59 if !response.status().is_success() {
60 let status = response.status().as_u16();
61 let body = response.text().await.unwrap_or_default();
62 return Err(ClientError::from_http(status, &body));
63 }
64
65 let body = response
66 .json::<UploadBaselineResponse>()
67 .await
68 .map_err(ClientError::RequestError)?;
69 Ok(body)
70 }
71 })
72 .await
73 }
74
75 pub async fn get_latest_baseline(
77 &self,
78 project: &str,
79 benchmark: &str,
80 ) -> Result<BaselineRecord, ClientError> {
81 let url = self.url(&format!(
82 "projects/{}/baselines/{}/latest",
83 project, benchmark
84 ));
85 debug!(url = %url, "Getting latest baseline");
86
87 let response = self
88 .execute_with_retry(|| {
89 let client = self.inner.clone();
90 let url = url.clone();
91 async move {
92 let resp = client
93 .get(url)
94 .send()
95 .await
96 .map_err(ClientError::RequestError)?;
97
98 if !resp.status().is_success() {
99 let status = resp.status().as_u16();
100 let body = resp.text().await.unwrap_or_default();
101 return Err(ClientError::from_http(status, &body));
102 }
103
104 let body = resp
105 .json::<BaselineRecord>()
106 .await
107 .map_err(ClientError::RequestError)?;
108 Ok(body)
109 }
110 })
111 .await?;
112
113 Ok(response)
114 }
115
116 pub async fn get_baseline_version(
118 &self,
119 project: &str,
120 benchmark: &str,
121 version: &str,
122 ) -> Result<BaselineRecord, ClientError> {
123 let url = self.url(&format!(
124 "projects/{}/baselines/{}/versions/{}",
125 project, benchmark, version
126 ));
127 debug!(url = %url, version = %version, "Getting baseline version");
128
129 let response = self
130 .execute_with_retry(|| {
131 let client = self.inner.clone();
132 let url = url.clone();
133 async move {
134 let resp = client
135 .get(url)
136 .send()
137 .await
138 .map_err(ClientError::RequestError)?;
139
140 if !resp.status().is_success() {
141 let status = resp.status().as_u16();
142 let body = resp.text().await.unwrap_or_default();
143 return Err(ClientError::from_http(status, &body));
144 }
145
146 let body = resp
147 .json::<BaselineRecord>()
148 .await
149 .map_err(ClientError::RequestError)?;
150 Ok(body)
151 }
152 })
153 .await?;
154
155 Ok(response)
156 }
157
158 pub async fn promote_baseline(
160 &self,
161 project: &str,
162 benchmark: &str,
163 request: &PromoteBaselineRequest,
164 ) -> Result<PromoteBaselineResponse, ClientError> {
165 self.execute_with_retry(|| {
166 let url = self.url(&format!("projects/{}/baselines/{}/promote", project, benchmark));
167 debug!(url = %url, from = %request.from_version, to = %request.to_version, "Promoting baseline");
168
169 let client = self.inner.clone();
170 let request = request.clone();
171 async move {
172 let response = client
173 .post(url)
174 .json(&request)
175 .send()
176 .await
177 .map_err(ClientError::RequestError)?;
178
179 if !response.status().is_success() {
180 let status = response.status().as_u16();
181 let body = response.text().await.unwrap_or_default();
182 return Err(ClientError::from_http(status, &body));
183 }
184
185 let body = response.json::<PromoteBaselineResponse>().await
186 .map_err(ClientError::RequestError)?;
187 Ok(body)
188 }
189 })
190 .await
191 }
192
193 pub async fn list_baselines(
195 &self,
196 project: &str,
197 query: &ListBaselinesQuery,
198 ) -> Result<ListBaselinesResponse, ClientError> {
199 let mut url = self.url(&format!("projects/{}/baselines", project));
200
201 let params = query.to_query_params();
202 if !params.is_empty() {
203 let mut url_obj = url::Url::parse(&url).map_err(ClientError::UrlError)?;
204 {
205 let mut query_pairs = url_obj.query_pairs_mut();
206 for (k, v) in params {
207 query_pairs.append_pair(&k, &v);
208 }
209 }
210 url = url_obj.to_string();
211 }
212
213 debug!(url = %url, "Listing baselines");
214
215 let response = self
216 .execute_with_retry(|| {
217 let client = self.inner.clone();
218 let url = url.clone();
219 async move {
220 let resp = client
221 .get(url)
222 .send()
223 .await
224 .map_err(ClientError::RequestError)?;
225
226 if !resp.status().is_success() {
227 let status = resp.status().as_u16();
228 let body = resp.text().await.unwrap_or_default();
229 return Err(ClientError::from_http(status, &body));
230 }
231
232 let body = resp
233 .json::<ListBaselinesResponse>()
234 .await
235 .map_err(ClientError::RequestError)?;
236 Ok(body)
237 }
238 })
239 .await?;
240
241 Ok(response)
242 }
243
244 pub async fn delete_baseline(
246 &self,
247 project: &str,
248 benchmark: &str,
249 version: &str,
250 ) -> Result<(), ClientError> {
251 let url = self.url(&format!(
252 "projects/{}/baselines/{}/versions/{}",
253 project, benchmark, version
254 ));
255 debug!(url = %url, version = %version, "Deleting baseline version");
256
257 self.execute_with_retry(|| {
258 let client = self.inner.clone();
259 let url = url.clone();
260 async move {
261 let resp = client
262 .delete(url)
263 .send()
264 .await
265 .map_err(ClientError::RequestError)?;
266
267 if !resp.status().is_success() {
268 let status = resp.status().as_u16();
269 let body = resp.text().await.unwrap_or_default();
270 return Err(ClientError::from_http(status, &body));
271 }
272 Ok(())
273 }
274 })
275 .await?;
276
277 Ok(())
278 }
279
280 pub async fn submit_verdict(
282 &self,
283 project: &str,
284 request: &SubmitVerdictRequest,
285 ) -> Result<VerdictRecord, ClientError> {
286 self.execute_with_retry(|| {
287 let url = self.url(&format!("projects/{}/verdicts", project));
288 debug!(url = %url, benchmark = %request.benchmark, "Submitting verdict");
289
290 let client = self.inner.clone();
291 let request = request.clone();
292 async move {
293 let response = client
294 .post(url)
295 .json(&request)
296 .send()
297 .await
298 .map_err(ClientError::RequestError)?;
299
300 if !response.status().is_success() {
301 let status = response.status().as_u16();
302 let body = response.text().await.unwrap_or_default();
303 return Err(ClientError::from_http(status, &body));
304 }
305
306 let body = response
307 .json::<VerdictRecord>()
308 .await
309 .map_err(ClientError::RequestError)?;
310 Ok(body)
311 }
312 })
313 .await
314 }
315
316 pub async fn list_verdicts(
318 &self,
319 project: &str,
320 query: &ListVerdictsQuery,
321 ) -> Result<ListVerdictsResponse, ClientError> {
322 self.execute_with_retry(|| {
323 let url = self.url(&format!("projects/{}/verdicts", project));
324 debug!(url = %url, "Listing verdicts");
325
326 let client = self.inner.clone();
327 let query = query.clone();
328 async move {
329 let response = client
330 .get(url)
331 .query(&query)
332 .send()
333 .await
334 .map_err(ClientError::RequestError)?;
335
336 if !response.status().is_success() {
337 let status = response.status().as_u16();
338 let body = response.text().await.unwrap_or_default();
339 return Err(ClientError::from_http(status, &body));
340 }
341
342 let body = response
343 .json::<ListVerdictsResponse>()
344 .await
345 .map_err(ClientError::RequestError)?;
346 Ok(body)
347 }
348 })
349 .await
350 }
351
352 pub async fn upload_decision(
354 &self,
355 project: &str,
356 request: &UploadDecisionRequest,
357 ) -> Result<DecisionRecord, ClientError> {
358 self.execute_with_retry(|| {
359 let url = self.url(&format!("projects/{}/decisions", project));
360 debug!(url = %url, "Uploading performance decision");
361
362 let client = self.inner.clone();
363 let request = request.clone();
364 async move {
365 let response = client
366 .post(url)
367 .json(&request)
368 .send()
369 .await
370 .map_err(ClientError::RequestError)?;
371
372 if !response.status().is_success() {
373 let status = response.status().as_u16();
374 let body = response.text().await.unwrap_or_default();
375 return Err(ClientError::from_http(status, &body));
376 }
377
378 let body = response
379 .json::<DecisionRecord>()
380 .await
381 .map_err(ClientError::RequestError)?;
382 Ok(body)
383 }
384 })
385 .await
386 }
387
388 pub async fn list_decisions(
390 &self,
391 project: &str,
392 query: &ListDecisionsQuery,
393 ) -> Result<ListDecisionsResponse, ClientError> {
394 self.execute_with_retry(|| {
395 let url = self.url(&format!("projects/{}/decisions", project));
396 debug!(url = %url, "Listing performance decisions");
397
398 let client = self.inner.clone();
399 let query = query.clone();
400 async move {
401 let response = client
402 .get(url)
403 .query(&query)
404 .send()
405 .await
406 .map_err(ClientError::RequestError)?;
407
408 if !response.status().is_success() {
409 let status = response.status().as_u16();
410 let body = response.text().await.unwrap_or_default();
411 return Err(ClientError::from_http(status, &body));
412 }
413
414 let body = response
415 .json::<ListDecisionsResponse>()
416 .await
417 .map_err(ClientError::RequestError)?;
418 Ok(body)
419 }
420 })
421 .await
422 }
423
424 pub async fn latest_decision(&self, project: &str) -> Result<DecisionRecord, ClientError> {
426 self.execute_with_retry(|| {
427 let url = self.url(&format!("projects/{}/decisions/latest", project));
428 debug!(url = %url, "Getting latest performance decision");
429
430 let client = self.inner.clone();
431 async move {
432 let response = client
433 .get(url)
434 .send()
435 .await
436 .map_err(ClientError::RequestError)?;
437
438 if !response.status().is_success() {
439 let status = response.status().as_u16();
440 let body = response.text().await.unwrap_or_default();
441 return Err(ClientError::from_http(status, &body));
442 }
443
444 let body = response
445 .json::<DecisionRecord>()
446 .await
447 .map_err(ClientError::RequestError)?;
448 Ok(body)
449 }
450 })
451 .await
452 }
453
454 pub async fn prune_decisions(
456 &self,
457 project: &str,
458 request: &PruneDecisionsRequest,
459 ) -> Result<PruneDecisionsResponse, ClientError> {
460 self.execute_with_retry(|| {
461 let url = self.url(&format!("projects/{}/decisions/prune", project));
462 debug!(url = %url, "Pruning performance decisions");
463
464 let client = self.inner.clone();
465 let request = request.clone();
466 async move {
467 let response = client
468 .post(url)
469 .json(&request)
470 .send()
471 .await
472 .map_err(ClientError::RequestError)?;
473
474 if !response.status().is_success() {
475 let status = response.status().as_u16();
476 let body = response.text().await.unwrap_or_default();
477 return Err(ClientError::from_http(status, &body));
478 }
479
480 let body = response
481 .json::<PruneDecisionsResponse>()
482 .await
483 .map_err(ClientError::RequestError)?;
484 Ok(body)
485 }
486 })
487 .await
488 }
489
490 pub async fn list_audit_events(
492 &self,
493 query: &ListAuditEventsQuery,
494 ) -> Result<ListAuditEventsResponse, ClientError> {
495 self.execute_with_retry(|| {
496 let url = self.url("audit");
497 debug!(url = %url, "Listing audit events");
498
499 let client = self.inner.clone();
500 let query = query.clone();
501 async move {
502 let response = client
503 .get(url)
504 .query(&query)
505 .send()
506 .await
507 .map_err(ClientError::RequestError)?;
508
509 if !response.status().is_success() {
510 let status = response.status().as_u16();
511 let body = response.text().await.unwrap_or_default();
512 return Err(ClientError::from_http(status, &body));
513 }
514
515 let body = response
516 .json::<ListAuditEventsResponse>()
517 .await
518 .map_err(ClientError::RequestError)?;
519 Ok(body)
520 }
521 })
522 .await
523 }
524
525 pub async fn health_check(&self) -> Result<HealthResponse, ClientError> {
527 let url = self.url("health");
528 debug!(url = %url, "Checking health");
529
530 let response = self
531 .execute_with_retry(|| {
532 let client = self.inner.clone();
533 let url = url.clone();
534 async move {
535 let resp = client
536 .get(url)
537 .send()
538 .await
539 .map_err(ClientError::RequestError)?;
540
541 if !resp.status().is_success() {
542 let status = resp.status().as_u16();
543 let body = resp.text().await.unwrap_or_default();
544 return Err(ClientError::from_http(status, &body));
545 }
546
547 let body = resp
548 .json::<HealthResponse>()
549 .await
550 .map_err(ClientError::RequestError)?;
551 Ok(body)
552 }
553 })
554 .await?;
555
556 Ok(response)
557 }
558
559 pub async fn is_healthy(&self) -> bool {
561 match self.health_check().await {
562 Ok(h) => h.status == "healthy",
563 Err(_) => false,
564 }
565 }
566
567 pub async fn create_key(
569 &self,
570 request: &CreateKeyRequest,
571 ) -> Result<CreateKeyResponse, ClientError> {
572 self.execute_with_retry(|| {
573 let url = self.url("keys");
574 debug!(url = %url, role = %request.role, project = %request.project, "Creating API key");
575
576 let client = self.inner.clone();
577 let request = request.clone();
578 async move {
579 let response = client
580 .post(url)
581 .json(&request)
582 .send()
583 .await
584 .map_err(ClientError::RequestError)?;
585
586 if !response.status().is_success() {
587 let status = response.status().as_u16();
588 let body = response.text().await.unwrap_or_default();
589 return Err(ClientError::from_http(status, &body));
590 }
591
592 let body = response
593 .json::<CreateKeyResponse>()
594 .await
595 .map_err(ClientError::RequestError)?;
596 Ok(body)
597 }
598 })
599 .await
600 }
601
602 pub async fn list_keys(&self) -> Result<ListKeysResponse, ClientError> {
604 self.execute_with_retry(|| {
605 let url = self.url("keys");
606 debug!(url = %url, "Listing API keys");
607
608 let client = self.inner.clone();
609 async move {
610 let response = client
611 .get(url)
612 .send()
613 .await
614 .map_err(ClientError::RequestError)?;
615
616 if !response.status().is_success() {
617 let status = response.status().as_u16();
618 let body = response.text().await.unwrap_or_default();
619 return Err(ClientError::from_http(status, &body));
620 }
621
622 let body = response
623 .json::<ListKeysResponse>()
624 .await
625 .map_err(ClientError::RequestError)?;
626 Ok(body)
627 }
628 })
629 .await
630 }
631
632 pub async fn revoke_key(&self, id: &str) -> Result<RevokeKeyResponse, ClientError> {
634 self.execute_with_retry(|| {
635 let url = self.url(&format!("keys/{}", id));
636 debug!(url = %url, key_id = %id, "Revoking API key");
637
638 let client = self.inner.clone();
639 async move {
640 let response = client
641 .delete(url)
642 .send()
643 .await
644 .map_err(ClientError::RequestError)?;
645
646 if !response.status().is_success() {
647 let status = response.status().as_u16();
648 let body = response.text().await.unwrap_or_default();
649 return Err(ClientError::from_http(status, &body));
650 }
651
652 let body = response
653 .json::<RevokeKeyResponse>()
654 .await
655 .map_err(ClientError::RequestError)?;
656 Ok(body)
657 }
658 })
659 .await
660 }
661
662 pub async fn record_dependency_event(
668 &self,
669 request: &RecordDependencyEventRequest,
670 ) -> Result<RecordDependencyEventResponse, ClientError> {
671 self.execute_with_retry(|| {
672 let url = self.url("fleet/dependency-event");
673 debug!(url = %url, project = %request.project, "Recording dependency event");
674
675 let client = self.inner.clone();
676 let request = request.clone();
677 async move {
678 let response = client
679 .post(url)
680 .json(&request)
681 .send()
682 .await
683 .map_err(ClientError::RequestError)?;
684
685 if !response.status().is_success() {
686 let status = response.status().as_u16();
687 let body = response.text().await.unwrap_or_default();
688 return Err(ClientError::from_http(status, &body));
689 }
690
691 let body = response
692 .json::<RecordDependencyEventResponse>()
693 .await
694 .map_err(ClientError::RequestError)?;
695 Ok(body)
696 }
697 })
698 .await
699 }
700
701 pub async fn list_fleet_alerts(
703 &self,
704 query: &ListFleetAlertsQuery,
705 ) -> Result<ListFleetAlertsResponse, ClientError> {
706 self.execute_with_retry(|| {
707 let url = self.url("fleet/alerts");
708 debug!(url = %url, "Listing fleet alerts");
709
710 let client = self.inner.clone();
711 let query = query.clone();
712 async move {
713 let response = client
714 .get(url)
715 .query(&query)
716 .send()
717 .await
718 .map_err(ClientError::RequestError)?;
719
720 if !response.status().is_success() {
721 let status = response.status().as_u16();
722 let body = response.text().await.unwrap_or_default();
723 return Err(ClientError::from_http(status, &body));
724 }
725
726 let body = response
727 .json::<ListFleetAlertsResponse>()
728 .await
729 .map_err(ClientError::RequestError)?;
730 Ok(body)
731 }
732 })
733 .await
734 }
735
736 pub async fn dependency_impact(
738 &self,
739 dep_name: &str,
740 query: &DependencyImpactQuery,
741 ) -> Result<DependencyImpactResponse, ClientError> {
742 self.execute_with_retry(|| {
743 let url = self.url(&format!("fleet/dependency/{}/impact", dep_name));
744 debug!(url = %url, dep = %dep_name, "Getting dependency impact");
745
746 let client = self.inner.clone();
747 let query = query.clone();
748 async move {
749 let response = client
750 .get(url)
751 .query(&query)
752 .send()
753 .await
754 .map_err(ClientError::RequestError)?;
755
756 if !response.status().is_success() {
757 let status = response.status().as_u16();
758 let body = response.text().await.unwrap_or_default();
759 return Err(ClientError::from_http(status, &body));
760 }
761
762 let body = response
763 .json::<DependencyImpactResponse>()
764 .await
765 .map_err(ClientError::RequestError)?;
766 Ok(body)
767 }
768 })
769 .await
770 }
771
772 fn url(&self, path: &str) -> String {
773 let mut base = self.config.server_url.clone();
774 if !base.ends_with('/') {
775 base.push('/');
776 }
777 format!("{}{}", base, path)
778 }
779
780 async fn execute_with_retry<F, Fut, T>(&self, mut operation: F) -> Result<T, ClientError>
781 where
782 F: FnMut() -> Fut,
783 Fut: std::future::Future<Output = Result<T, ClientError>>,
784 {
785 let mut attempts = 0;
786
787 loop {
788 match operation().await {
789 Ok(result) => return Ok(result),
790 Err(e) => {
791 attempts += 1;
792 let is_retryable = e.is_retryable();
793
794 if !is_retryable || attempts > self.config.retry.max_retries {
795 return Err(e);
796 }
797
798 debug!(error = %e, attempt = attempts, "Request failed, retrying");
799 tokio::time::sleep(self.config.retry.delay_for_attempt(attempts)).await;
800 }
801 }
802 }
803 }
804}
805
806#[cfg(test)]
807mod tests {
808 use super::*;
809 use wiremock::matchers::{method, path, query_param};
810 use wiremock::{Mock, MockServer, ResponseTemplate};
811
812 fn test_config(url: &str) -> ClientConfig {
813 ClientConfig::new(url)
814 }
815
816 #[tokio::test]
817 async fn test_get_latest_baseline() {
818 let mock_server = MockServer::start().await;
819
820 Mock::given(method("GET"))
821 .and(path("/projects/my-project/baselines/my-bench/latest"))
822 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
823 "schema": "perfgate.baseline.v1",
824 "id": "bl_123",
825 "project": "my-project",
826 "benchmark": "my-bench",
827 "version": "v1.2.3",
828 "receipt": {
829 "schema": "perfgate.run.v1",
830 "tool": {"name": "test", "version": "0"},
831 "run": {"id": "r1", "started_at": "2024-01-01T00:00:00Z", "ended_at": "2024-01-01T00:00:01Z", "host": {"os": "linux", "arch": "x86_64"}},
832 "bench": {"name": "my-bench", "command": [], "repeat": 1, "warmup": 0},
833 "samples": [],
834 "stats": {"wall_ms": {"median": 100, "min": 100, "max": 100}}
835 },
836 "metadata": {},
837 "tags": [],
838 "created_at": "2024-01-01T00:00:00Z",
839 "updated_at": "2024-01-01T00:00:00Z",
840 "content_hash": "hash123",
841 "source": "upload",
842 "deleted": false
843 })))
844 .mount(&mock_server)
845 .await;
846
847 let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
848 let result = client
849 .get_latest_baseline("my-project", "my-bench")
850 .await
851 .unwrap();
852
853 assert_eq!(result.id, "bl_123");
854 assert_eq!(result.version, "v1.2.3");
855 }
856
857 #[tokio::test]
858 async fn test_promote_baseline() {
859 let mock_server = MockServer::start().await;
860
861 Mock::given(method("POST"))
862 .and(path("/projects/my-project/baselines/my-bench/promote"))
863 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
864 "id": "bl_new",
865 "benchmark": "my-bench",
866 "version": "v2.0.0",
867 "promoted_from": "v1.0.0",
868 "promoted_at": "2024-01-01T00:00:00Z",
869 "created_at": "2024-01-01T00:00:00Z"
870 })))
871 .mount(&mock_server)
872 .await;
873
874 let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
875 let request = PromoteBaselineRequest {
876 from_version: "v1.0.0".to_string(),
877 to_version: "v2.0.0".to_string(),
878 git_ref: None,
879 git_sha: None,
880 tags: vec![],
881 normalize: true,
882 };
883 let response = client
884 .promote_baseline("my-project", "my-bench", &request)
885 .await
886 .unwrap();
887
888 assert_eq!(response.version, "v2.0.0");
889 assert_eq!(response.promoted_from, "v1.0.0");
890 }
891
892 #[tokio::test]
893 async fn test_list_audit_events() {
894 let mock_server = MockServer::start().await;
895
896 Mock::given(method("GET"))
897 .and(path("/audit"))
898 .and(query_param("project", "my-project"))
899 .and(query_param("action", "create"))
900 .and(query_param("resource_type", "baseline"))
901 .and(query_param("limit", "25"))
902 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
903 "events": [{
904 "id": "audit_123",
905 "timestamp": "2026-01-01T00:00:00Z",
906 "actor": "key-admin",
907 "action": "create",
908 "resource_type": "baseline",
909 "resource_id": "bl_123",
910 "project": "my-project",
911 "metadata": {"benchmark": "parser"}
912 }],
913 "pagination": {
914 "limit": 25,
915 "offset": 0,
916 "total": 1,
917 "has_more": false
918 }
919 })))
920 .mount(&mock_server)
921 .await;
922
923 let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
924 let response = client
925 .list_audit_events(&ListAuditEventsQuery {
926 project: Some("my-project".to_string()),
927 action: Some("create".to_string()),
928 resource_type: Some("baseline".to_string()),
929 limit: 25,
930 ..ListAuditEventsQuery::default()
931 })
932 .await
933 .unwrap();
934
935 assert_eq!(response.events.len(), 1);
936 assert_eq!(response.events[0].id, "audit_123");
937 assert_eq!(response.events[0].project, "my-project");
938 }
939
940 #[tokio::test]
941 async fn test_prune_decisions() {
942 let mock_server = MockServer::start().await;
943
944 Mock::given(method("POST"))
945 .and(path("/projects/my-project/decisions/prune"))
946 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
947 "project": "my-project",
948 "older_than": "2026-01-01T00:00:00Z",
949 "dry_run": true,
950 "matched": 1,
951 "deleted": 0,
952 "decision_ids": ["decision-1"]
953 })))
954 .mount(&mock_server)
955 .await;
956
957 let client = BaselineClient::new(test_config(&mock_server.uri())).unwrap();
958 let response = client
959 .prune_decisions(
960 "my-project",
961 &PruneDecisionsRequest {
962 older_than: chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
963 .unwrap()
964 .with_timezone(&chrono::Utc),
965 dry_run: true,
966 },
967 )
968 .await
969 .unwrap();
970
971 assert_eq!(response.project, "my-project");
972 assert!(response.dry_run);
973 assert_eq!(response.matched, 1);
974 assert_eq!(response.deleted, 0);
975 assert_eq!(response.decision_ids, vec!["decision-1".to_string()]);
976 }
977}