Skip to main content

perfgate_client/
client.rs

1//! Client for the perfgate baseline service.
2
3use crate::config::ClientConfig;
4use crate::error::ClientError;
5use crate::types::*;
6use reqwest::header::{self, HeaderMap, HeaderValue};
7use tracing::debug;
8
9/// High-level client for the perfgate baseline service.
10#[derive(Clone, Debug)]
11pub struct BaselineClient {
12    config: ClientConfig,
13    inner: reqwest::Client,
14}
15
16impl BaselineClient {
17    /// Creates a new BaselineClient from the given configuration.
18    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    /// Uploads a new baseline to the server.
40    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    /// Gets the latest baseline for a benchmark.
76    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    /// Gets a specific version of a baseline.
117    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    /// Promotes a baseline to a new version.
159    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    /// Lists baselines for a project.
194    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    /// Deletes a baseline from the server.
245    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    /// Submits a benchmark verdict to the server.
281    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    /// Lists verdicts for a project.
317    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    /// Uploads a structured performance decision receipt to the server ledger.
353    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    /// Lists stored performance decisions for a project.
389    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    /// Gets the latest stored performance decision for a project.
425    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    /// Prunes old performance decisions from the server ledger.
455    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    /// Lists audit events. Requires an admin API key on authenticated servers.
491    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    /// Checks the health of the baseline service.
526    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    /// Returns true if the service is reachable and healthy.
560    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    /// Creates a new API key. The plaintext key is returned exactly once.
568    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    /// Lists API keys. Returned key material is redacted by the server.
603    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    /// Revokes an API key by ID.
633    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    // -----------------------------------------------------------------------
663    // Fleet-wide dependency regression detection
664    // -----------------------------------------------------------------------
665
666    /// Records dependency change events with their performance impact.
667    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    /// Lists fleet-wide dependency regression alerts.
702    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    /// Gets the impact of a specific dependency across all projects.
737    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}