1use async_trait::async_trait;
4use std::collections::BTreeMap;
5use std::sync::Arc;
6use tokio::sync::RwLock;
7
8use super::{AuditStore, BaselineStore, StorageHealth};
9use crate::error::StoreError;
10use crate::models::{
11 AuditEvent, BaselineRecord, BaselineSummary, BaselineVersion, DecisionRecord,
12 ListAuditEventsQuery, ListAuditEventsResponse, ListBaselinesQuery, ListBaselinesResponse,
13 ListDecisionsQuery, ListDecisionsResponse, ListVerdictsQuery, ListVerdictsResponse,
14 PaginationInfo, PruneDecisionsResponse, VerdictRecord,
15};
16
17#[derive(Debug, Default)]
19pub struct InMemoryStore {
20 #[allow(clippy::type_complexity)]
21 baselines: Arc<RwLock<BTreeMap<(String, String, String), BaselineRecord>>>,
22 verdicts: Arc<RwLock<Vec<VerdictRecord>>>,
23 decisions: Arc<RwLock<Vec<DecisionRecord>>>,
24 audit_events: Arc<RwLock<Vec<AuditEvent>>>,
25}
26
27impl InMemoryStore {
28 pub fn new() -> Self {
30 Self {
31 baselines: Arc::new(RwLock::new(BTreeMap::new())),
32 verdicts: Arc::new(RwLock::new(Vec::new())),
33 decisions: Arc::new(RwLock::new(Vec::new())),
34 audit_events: Arc::new(RwLock::new(Vec::new())),
35 }
36 }
37
38 fn key(project: &str, benchmark: &str, version: &str) -> (String, String, String) {
39 (
40 project.to_string(),
41 benchmark.to_string(),
42 version.to_string(),
43 )
44 }
45}
46
47#[async_trait]
48impl BaselineStore for InMemoryStore {
49 async fn create(&self, record: &BaselineRecord) -> Result<(), StoreError> {
50 let key = Self::key(&record.project, &record.benchmark, &record.version);
51 let mut baselines = self.baselines.write().await;
52
53 if baselines.contains_key(&key) {
54 return Err(StoreError::AlreadyExists(format!(
55 "project={}, benchmark={}, version={}",
56 record.project, record.benchmark, record.version
57 )));
58 }
59
60 baselines.insert(key, record.clone());
61 Ok(())
62 }
63
64 async fn get(
65 &self,
66 project: &str,
67 benchmark: &str,
68 version: &str,
69 ) -> Result<Option<BaselineRecord>, StoreError> {
70 let key = Self::key(project, benchmark, version);
71 let baselines = self.baselines.read().await;
72 Ok(baselines.get(&key).filter(|r| !r.deleted).cloned())
73 }
74
75 async fn get_latest(
76 &self,
77 project: &str,
78 benchmark: &str,
79 ) -> Result<Option<BaselineRecord>, StoreError> {
80 let baselines = self.baselines.read().await;
81 let latest = baselines
82 .values()
83 .filter(|r| r.project == project && r.benchmark == benchmark && !r.deleted)
84 .max_by_key(|r| r.created_at);
85 Ok(latest.cloned())
86 }
87
88 #[allow(clippy::collapsible_if)]
89 async fn list(
90 &self,
91 project: &str,
92 query: &ListBaselinesQuery,
93 ) -> Result<ListBaselinesResponse, StoreError> {
94 let baselines = self.baselines.read().await;
95 let parsed_tags = query.parsed_tags();
96
97 let mut filtered: Vec<_> = baselines
98 .values()
99 .filter(|r| {
100 if r.project != project || r.deleted {
102 return false;
103 }
104
105 if let Some(ref b) = query.benchmark {
107 if &r.benchmark != b {
108 return false;
109 }
110 }
111
112 if let Some(ref p) = query.benchmark_prefix {
114 if !r.benchmark.starts_with(p) {
115 return false;
116 }
117 }
118
119 if let Some(ref gr) = query.git_ref {
121 if r.git_ref.as_deref() != Some(gr) {
122 return false;
123 }
124 }
125
126 if let Some(ref gs) = query.git_sha {
128 if r.git_sha.as_deref() != Some(gs) {
129 return false;
130 }
131 }
132
133 if let Some(since) = query.since {
135 if r.created_at < since {
136 return false;
137 }
138 }
139
140 if let Some(until) = query.until {
142 if r.created_at > until {
143 return false;
144 }
145 }
146
147 if !parsed_tags.is_empty() {
149 for tag in &parsed_tags {
150 if !r.tags.contains(tag) {
151 return false;
152 }
153 }
154 }
155
156 true
157 })
158 .collect();
159
160 filtered.sort_by_key(|b| std::cmp::Reverse(b.created_at));
161
162 let total = filtered.len() as u64;
163 let offset = query.offset as usize;
164 let limit = query.limit as usize;
165
166 let paginated: Vec<_> = filtered
167 .into_iter()
168 .skip(offset)
169 .take(limit)
170 .map(|r| {
171 let mut summary: BaselineSummary = r.clone().into();
172 if query.include_receipt {
173 summary.receipt = Some(r.receipt.clone());
174 }
175 summary
176 })
177 .collect();
178
179 let has_more = (offset + paginated.len()) < total as usize;
180
181 Ok(ListBaselinesResponse {
182 baselines: paginated,
183 pagination: PaginationInfo {
184 total,
185 limit: query.limit,
186 offset: query.offset,
187 has_more,
188 },
189 })
190 }
191
192 async fn update(&self, record: &BaselineRecord) -> Result<(), StoreError> {
193 let key = Self::key(&record.project, &record.benchmark, &record.version);
194 let mut baselines = self.baselines.write().await;
195
196 if !baselines.contains_key(&key) {
197 return Err(StoreError::NotFound(format!(
198 "project={}, benchmark={}, version={}",
199 record.project, record.benchmark, record.version
200 )));
201 }
202
203 baselines.insert(key, record.clone());
204 Ok(())
205 }
206
207 async fn delete(
208 &self,
209 project: &str,
210 benchmark: &str,
211 version: &str,
212 ) -> Result<bool, StoreError> {
213 let key = Self::key(project, benchmark, version);
214 let mut baselines = self.baselines.write().await;
215
216 if let Some(record) = baselines.get_mut(&key) {
217 if record.deleted {
218 return Ok(false);
219 }
220 record.deleted = true;
221 return Ok(true);
222 }
223
224 Ok(false)
225 }
226
227 async fn hard_delete(
228 &self,
229 project: &str,
230 benchmark: &str,
231 version: &str,
232 ) -> Result<bool, StoreError> {
233 let key = Self::key(project, benchmark, version);
234 let mut baselines = self.baselines.write().await;
235 Ok(baselines.remove(&key).is_some())
236 }
237
238 async fn list_versions(
239 &self,
240 project: &str,
241 benchmark: &str,
242 ) -> Result<Vec<BaselineVersion>, StoreError> {
243 let baselines = self.baselines.read().await;
244
245 let mut versions: Vec<_> = baselines
246 .values()
247 .filter(|r| r.project == project && r.benchmark == benchmark && !r.deleted)
248 .map(|r| BaselineVersion {
249 version: r.version.clone(),
250 git_ref: r.git_ref.clone(),
251 git_sha: r.git_sha.clone(),
252 created_at: r.created_at,
253 created_by: None,
254 is_current: false,
255 source: r.source.clone(),
256 })
257 .collect();
258
259 versions.sort_by_key(|b| std::cmp::Reverse(b.created_at));
260
261 if let Some(first) = versions.first_mut() {
262 first.is_current = true;
263 }
264
265 Ok(versions)
266 }
267
268 async fn health_check(&self) -> Result<StorageHealth, StoreError> {
269 Ok(StorageHealth::Healthy)
270 }
271
272 fn backend_type(&self) -> &'static str {
273 "memory"
274 }
275
276 async fn create_verdict(&self, record: &VerdictRecord) -> Result<(), StoreError> {
277 let mut verdicts = self.verdicts.write().await;
278 verdicts.push(record.clone());
279 Ok(())
280 }
281
282 async fn list_verdicts(
283 &self,
284 project: &str,
285 query: &ListVerdictsQuery,
286 ) -> Result<ListVerdictsResponse, StoreError> {
287 let verdicts = self.verdicts.read().await;
288
289 let mut filtered: Vec<_> = verdicts
290 .iter()
291 .filter(|r| {
292 if r.project != project {
293 return false;
294 }
295
296 if let Some(ref b) = query.benchmark
297 && &r.benchmark != b
298 {
299 return false;
300 }
301
302 if let Some(ref s) = query.status
303 && &r.status != s
304 {
305 return false;
306 }
307
308 if let Some(since) = query.since
309 && r.created_at < since
310 {
311 return false;
312 }
313
314 if let Some(until) = query.until
315 && r.created_at > until
316 {
317 return false;
318 }
319
320 true
321 })
322 .cloned()
323 .collect();
324
325 filtered.sort_by_key(|b| std::cmp::Reverse(b.created_at));
326
327 let total = filtered.len() as u64;
328 let offset = query.offset as usize;
329 let limit = query.limit as usize;
330
331 let paginated: Vec<_> = filtered.into_iter().skip(offset).take(limit).collect();
332
333 let has_more = (offset + paginated.len()) < total as usize;
334
335 Ok(ListVerdictsResponse {
336 verdicts: paginated,
337 pagination: PaginationInfo {
338 total,
339 limit: query.limit,
340 offset: query.offset,
341 has_more,
342 },
343 })
344 }
345
346 async fn create_decision(&self, record: &DecisionRecord) -> Result<(), StoreError> {
347 let mut decisions = self.decisions.write().await;
348 decisions.push(record.clone());
349 Ok(())
350 }
351
352 async fn latest_decision(&self, project: &str) -> Result<Option<DecisionRecord>, StoreError> {
353 let decisions = self.decisions.read().await;
354 Ok(decisions
355 .iter()
356 .filter(|record| record.project == project)
357 .max_by_key(|record| record.created_at)
358 .cloned())
359 }
360
361 async fn list_decisions(
362 &self,
363 project: &str,
364 query: &ListDecisionsQuery,
365 ) -> Result<ListDecisionsResponse, StoreError> {
366 let decisions = self.decisions.read().await;
367 let mut filtered: Vec<_> = decisions
368 .iter()
369 .filter(|record| {
370 if record.project != project {
371 return false;
372 }
373 if let Some(ref scenario) = query.scenario
374 && record.scenario.as_deref() != Some(scenario)
375 {
376 return false;
377 }
378 if let Some(status) = query.status
379 && record.status != status
380 {
381 return false;
382 }
383 if let Some(verdict) = query.verdict
384 && record.verdict != verdict
385 {
386 return false;
387 }
388 if let Some(review_required) = query.review_required
389 && record.review_required != review_required
390 {
391 return false;
392 }
393 if let Some(accepted) = query.accepted {
394 let has_accepted_tradeoff = !record.accepted_rules.is_empty();
395 if has_accepted_tradeoff != accepted {
396 return false;
397 }
398 }
399 if let Some(ref rule) = query.rule
400 && !record
401 .accepted_rules
402 .iter()
403 .any(|accepted| accepted == rule)
404 {
405 return false;
406 }
407 true
408 })
409 .cloned()
410 .collect();
411
412 filtered.sort_by_key(|record| std::cmp::Reverse(record.created_at));
413 let total = filtered.len() as u64;
414 let offset = query.offset as usize;
415 let limit = query.limit as usize;
416 let paginated: Vec<_> = filtered.into_iter().skip(offset).take(limit).collect();
417 let has_more = (offset + paginated.len()) < total as usize;
418
419 Ok(ListDecisionsResponse {
420 decisions: paginated,
421 pagination: PaginationInfo {
422 total,
423 limit: query.limit,
424 offset: query.offset,
425 has_more,
426 },
427 })
428 }
429
430 async fn prune_decisions(
431 &self,
432 project: &str,
433 older_than: chrono::DateTime<chrono::Utc>,
434 dry_run: bool,
435 ) -> Result<PruneDecisionsResponse, StoreError> {
436 let mut decisions = self.decisions.write().await;
437 let decision_ids: Vec<String> = decisions
438 .iter()
439 .filter(|record| record.project == project && record.created_at < older_than)
440 .map(|record| record.id.clone())
441 .collect();
442 let matched = decision_ids.len() as u64;
443
444 let deleted = if dry_run {
445 0
446 } else {
447 decisions
448 .retain(|record| !(record.project == project && record.created_at < older_than));
449 matched
450 };
451
452 Ok(PruneDecisionsResponse {
453 project: project.to_string(),
454 older_than,
455 dry_run,
456 matched,
457 deleted,
458 decision_ids,
459 })
460 }
461}
462
463#[async_trait]
464impl AuditStore for InMemoryStore {
465 async fn log_event(&self, event: &AuditEvent) -> Result<(), StoreError> {
466 let mut events = self.audit_events.write().await;
467 events.push(event.clone());
468 Ok(())
469 }
470
471 async fn list_events(
472 &self,
473 query: &ListAuditEventsQuery,
474 ) -> Result<ListAuditEventsResponse, StoreError> {
475 let events = self.audit_events.read().await;
476
477 let mut filtered: Vec<_> = events
478 .iter()
479 .filter(|e| {
480 if let Some(ref project) = query.project
481 && &e.project != project
482 {
483 return false;
484 }
485
486 if let Some(ref action) = query.action
487 && e.action.to_string() != *action
488 {
489 return false;
490 }
491
492 if let Some(ref resource_type) = query.resource_type
493 && e.resource_type.to_string() != *resource_type
494 {
495 return false;
496 }
497
498 if let Some(ref actor) = query.actor
499 && &e.actor != actor
500 {
501 return false;
502 }
503
504 if let Some(since) = query.since
505 && e.timestamp < since
506 {
507 return false;
508 }
509
510 if let Some(until) = query.until
511 && e.timestamp > until
512 {
513 return false;
514 }
515
516 true
517 })
518 .cloned()
519 .collect();
520
521 filtered.sort_by_key(|b| std::cmp::Reverse(b.timestamp));
522
523 let total = filtered.len() as u64;
524 let offset = query.offset as usize;
525 let limit = query.limit as usize;
526
527 let paginated: Vec<_> = filtered.into_iter().skip(offset).take(limit).collect();
528
529 let has_more = (offset + paginated.len()) < total as usize;
530
531 Ok(ListAuditEventsResponse {
532 events: paginated,
533 pagination: PaginationInfo {
534 total,
535 limit: query.limit,
536 offset: query.offset,
537 has_more,
538 },
539 })
540 }
541}
542#[cfg(test)]
543mod tests {
544 use super::*;
545 use crate::models::{
546 AuditAction, AuditResourceType, BaselineRecordExt, BaselineSource, BaselineVersion,
547 };
548 use chrono::{Duration, TimeZone, Utc};
549 use perfgate_types::{
550 BenchMeta, HostInfo, MetricStatus, RunMeta, RunReceipt, Stats, ToolInfo, U64Summary,
551 VerdictCounts, VerdictStatus,
552 };
553
554 fn dummy_receipt(bench: &str) -> RunReceipt {
555 RunReceipt {
556 schema: "perfgate.run.v1".to_string(),
557 tool: ToolInfo {
558 name: "perfgate".to_string(),
559 version: "0.0.0-test".to_string(),
560 },
561 run: RunMeta {
562 id: "test-run".to_string(),
563 started_at: "2026-01-01T00:00:00Z".to_string(),
564 ended_at: "2026-01-01T00:00:01Z".to_string(),
565 host: HostInfo {
566 os: "linux".to_string(),
567 arch: "x86_64".to_string(),
568 cpu_count: Some(4),
569 memory_bytes: Some(8 * 1024 * 1024 * 1024),
570 hostname_hash: None,
571 },
572 },
573 bench: BenchMeta {
574 name: bench.to_string(),
575 cwd: None,
576 command: vec!["true".to_string()],
577 repeat: 1,
578 warmup: 0,
579 work_units: None,
580 timeout_ms: None,
581 },
582 samples: vec![],
583 stats: Stats {
584 wall_ms: U64Summary::new(10, 9, 11),
585 cpu_ms: None,
586 page_faults: None,
587 ctx_switches: None,
588 max_rss_kb: None,
589 io_read_bytes: None,
590 io_write_bytes: None,
591 network_packets: None,
592 energy_uj: None,
593 binary_bytes: None,
594 throughput_per_s: None,
595 },
596 }
597 }
598
599 fn record(
600 project: &str,
601 benchmark: &str,
602 version: &str,
603 git_ref: Option<&str>,
604 git_sha: Option<&str>,
605 tags: Vec<&str>,
606 ) -> BaselineRecord {
607 BaselineRecord::new(
608 project.to_string(),
609 benchmark.to_string(),
610 version.to_string(),
611 dummy_receipt(benchmark),
612 git_ref.map(String::from),
613 git_sha.map(String::from),
614 BTreeMap::new(),
615 tags.into_iter().map(String::from).collect(),
616 BaselineSource::Upload,
617 )
618 }
619
620 fn record_at(
623 project: &str,
624 benchmark: &str,
625 version: &str,
626 ts: chrono::DateTime<Utc>,
627 ) -> BaselineRecord {
628 let mut r = record(project, benchmark, version, None, None, vec![]);
629 r.created_at = ts;
630 r.updated_at = ts;
631 r
632 }
633
634 fn verdict_at(
635 project: &str,
636 benchmark: &str,
637 status: VerdictStatus,
638 ts: chrono::DateTime<Utc>,
639 ) -> VerdictRecord {
640 VerdictRecord {
641 schema: "perfgate.verdict.v1".to_string(),
642 id: format!("v-{}-{}", benchmark, ts.timestamp_millis()),
643 project: project.to_string(),
644 benchmark: benchmark.to_string(),
645 run_id: format!("run-{}", ts.timestamp_millis()),
646 status,
647 counts: VerdictCounts {
648 pass: 1,
649 warn: 0,
650 fail: 0,
651 skip: 0,
652 },
653 reasons: Vec::new(),
654 git_ref: None,
655 git_sha: None,
656 wall_ms_cv: None,
657 flakiness_score: None,
658 created_at: ts,
659 }
660 }
661
662 fn audit_at(
663 project: &str,
664 actor: &str,
665 action: AuditAction,
666 resource_type: AuditResourceType,
667 resource_id: &str,
668 ts: chrono::DateTime<Utc>,
669 ) -> AuditEvent {
670 AuditEvent {
671 id: format!("evt-{}", ts.timestamp_millis()),
672 timestamp: ts,
673 actor: actor.to_string(),
674 action,
675 resource_type,
676 resource_id: resource_id.to_string(),
677 project: project.to_string(),
678 metadata: serde_json::Value::Null,
679 }
680 }
681
682 fn day(n: i64) -> chrono::DateTime<Utc> {
683 Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap() + Duration::days(n)
684 }
685
686 #[tokio::test]
687 async fn new_and_default_produce_empty_stores() {
688 let s = InMemoryStore::new();
689 let default_store = InMemoryStore::default();
690 for store in [&s, &default_store] {
691 assert_eq!(store.backend_type(), "memory");
692 assert!(matches!(
693 store.health_check().await.unwrap(),
694 StorageHealth::Healthy
695 ));
696 assert!(store.get("p", "b", "v").await.unwrap().is_none());
697 assert!(store.get_latest("p", "b").await.unwrap().is_none());
698 }
699 }
700
701 #[tokio::test]
702 async fn create_then_get_round_trip() {
703 let s = InMemoryStore::new();
704 let r = record(
705 "p",
706 "b",
707 "v1",
708 Some("refs/heads/main"),
709 Some("abc"),
710 vec!["a"],
711 );
712 s.create(&r).await.unwrap();
713 let got = s.get("p", "b", "v1").await.unwrap().unwrap();
714 assert_eq!(got.version, "v1");
715 assert_eq!(got.git_ref.as_deref(), Some("refs/heads/main"));
716 assert_eq!(got.tags, vec!["a".to_string()]);
717 }
718
719 #[tokio::test]
720 async fn create_duplicate_returns_already_exists() {
721 let s = InMemoryStore::new();
722 let r = record("p", "b", "v1", None, None, vec![]);
723 s.create(&r).await.unwrap();
724 let err = s.create(&r).await.expect_err("expected duplicate error");
725 assert!(matches!(err, StoreError::AlreadyExists(_)));
726 }
727
728 #[tokio::test]
729 async fn get_treats_soft_deleted_record_as_absent() {
730 let s = InMemoryStore::new();
731 s.create(&record("p", "b", "v1", None, None, vec![]))
732 .await
733 .unwrap();
734 assert!(s.delete("p", "b", "v1").await.unwrap());
735 assert!(s.get("p", "b", "v1").await.unwrap().is_none());
736 assert!(!s.delete("p", "b", "v1").await.unwrap());
738 }
739
740 #[tokio::test]
741 async fn delete_missing_returns_false() {
742 let s = InMemoryStore::new();
743 assert!(!s.delete("p", "b", "missing").await.unwrap());
744 assert!(!s.hard_delete("p", "b", "missing").await.unwrap());
745 }
746
747 #[tokio::test]
748 async fn hard_delete_removes_record_completely() {
749 let s = InMemoryStore::new();
750 s.create(&record("p", "b", "v1", None, None, vec![]))
751 .await
752 .unwrap();
753 assert!(s.hard_delete("p", "b", "v1").await.unwrap());
754 s.create(&record("p", "b", "v1", None, None, vec![]))
756 .await
757 .unwrap();
758 }
759
760 #[tokio::test]
761 async fn update_existing_record_succeeds_and_update_missing_errors() {
762 let s = InMemoryStore::new();
763 let mut r = record("p", "b", "v1", None, None, vec!["old"]);
764 s.create(&r).await.unwrap();
765 r.tags = vec!["new".to_string()];
766 s.update(&r).await.unwrap();
767 let got = s.get("p", "b", "v1").await.unwrap().unwrap();
768 assert_eq!(got.tags, vec!["new".to_string()]);
769
770 let missing = record("p", "b", "vmissing", None, None, vec![]);
771 let err = s.update(&missing).await.expect_err("expected not found");
772 assert!(matches!(err, StoreError::NotFound(_)));
773 }
774
775 #[tokio::test]
776 async fn get_latest_returns_record_with_max_created_at_skipping_deleted() {
777 let s = InMemoryStore::new();
778 s.create(&record_at("p", "b", "v1", day(1))).await.unwrap();
779 s.create(&record_at("p", "b", "v2", day(3))).await.unwrap();
780 s.create(&record_at("p", "b", "v3", day(2))).await.unwrap();
781 let latest = s.get_latest("p", "b").await.unwrap().unwrap();
782 assert_eq!(latest.version, "v2");
783
784 s.delete("p", "b", "v2").await.unwrap();
785 let latest = s.get_latest("p", "b").await.unwrap().unwrap();
786 assert_eq!(latest.version, "v3");
787 }
788
789 #[tokio::test]
790 async fn get_latest_filters_by_project_and_benchmark() {
791 let s = InMemoryStore::new();
792 s.create(&record_at("other", "b", "v1", day(5)))
793 .await
794 .unwrap();
795 s.create(&record_at("p", "other-bench", "v1", day(5)))
796 .await
797 .unwrap();
798 s.create(&record_at("p", "b", "v1", day(1))).await.unwrap();
799 let latest = s.get_latest("p", "b").await.unwrap().unwrap();
800 assert_eq!(latest.project, "p");
801 assert_eq!(latest.benchmark, "b");
802 assert_eq!(latest.version, "v1");
803 }
804
805 #[tokio::test]
806 async fn list_filters_by_benchmark_exact_and_prefix() {
807 let s = InMemoryStore::new();
808 for (b, v) in [("bench-a", "v1"), ("bench-ab", "v1"), ("other", "v1")] {
809 s.create(&record_at("p", b, v, day(1))).await.unwrap();
810 }
811 let q = ListBaselinesQuery::new().with_benchmark("bench-a");
813 let resp = s.list("p", &q).await.unwrap();
814 let names: Vec<_> = resp.baselines.iter().map(|b| b.benchmark.clone()).collect();
815 assert_eq!(names, vec!["bench-a".to_string()]);
816 assert_eq!(resp.pagination.total, 1);
817
818 let q = ListBaselinesQuery::new().with_benchmark_prefix("bench-");
820 let resp = s.list("p", &q).await.unwrap();
821 let mut names: Vec<_> = resp.baselines.iter().map(|b| b.benchmark.clone()).collect();
822 names.sort();
823 assert_eq!(names, vec!["bench-a".to_string(), "bench-ab".to_string()]);
824 }
825
826 #[tokio::test]
827 async fn list_filters_by_git_ref_git_sha_and_tags() {
828 let s = InMemoryStore::new();
829 s.create(&record(
830 "p",
831 "b",
832 "v1",
833 Some("refs/heads/main"),
834 Some("aaa"),
835 vec!["release", "smoke"],
836 ))
837 .await
838 .unwrap();
839 s.create(&record(
840 "p",
841 "b",
842 "v2",
843 Some("refs/heads/main"),
844 Some("bbb"),
845 vec!["smoke"],
846 ))
847 .await
848 .unwrap();
849 s.create(&record(
850 "p",
851 "b",
852 "v3",
853 Some("refs/heads/dev"),
854 Some("ccc"),
855 vec!["release"],
856 ))
857 .await
858 .unwrap();
859
860 let q = ListBaselinesQuery {
862 git_ref: Some("refs/heads/main".into()),
863 ..Default::default()
864 };
865 let resp = s.list("p", &q).await.unwrap();
866 assert_eq!(resp.pagination.total, 2);
867
868 let q = ListBaselinesQuery {
870 git_sha: Some("bbb".into()),
871 ..Default::default()
872 };
873 let resp = s.list("p", &q).await.unwrap();
874 assert_eq!(resp.pagination.total, 1);
875 assert_eq!(resp.baselines[0].version, "v2");
876
877 let q = ListBaselinesQuery {
879 tags: Some("release,smoke".into()),
880 ..Default::default()
881 };
882 let resp = s.list("p", &q).await.unwrap();
883 assert_eq!(resp.pagination.total, 1);
884 assert_eq!(resp.baselines[0].version, "v1");
885 }
886
887 #[tokio::test]
888 async fn list_filters_by_since_until_window() {
889 let s = InMemoryStore::new();
890 s.create(&record_at("p", "b", "v1", day(1))).await.unwrap();
891 s.create(&record_at("p", "b", "v2", day(5))).await.unwrap();
892 s.create(&record_at("p", "b", "v3", day(10))).await.unwrap();
893
894 let q = ListBaselinesQuery {
895 since: Some(day(4)),
896 until: Some(day(9)),
897 ..Default::default()
898 };
899 let resp = s.list("p", &q).await.unwrap();
900 assert_eq!(resp.pagination.total, 1);
901 assert_eq!(resp.baselines[0].version, "v2");
902 }
903
904 #[tokio::test]
905 async fn list_paginates_in_descending_created_at_order() {
906 let s = InMemoryStore::new();
907 for i in 0..5 {
908 s.create(&record_at("p", "b", &format!("v{i}"), day(i)))
909 .await
910 .unwrap();
911 }
912 let q = ListBaselinesQuery {
913 limit: 2,
914 offset: 0,
915 ..Default::default()
916 };
917 let page1 = s.list("p", &q).await.unwrap();
918 assert_eq!(page1.baselines.len(), 2);
919 assert!(page1.pagination.has_more);
920 assert_eq!(page1.pagination.total, 5);
921 assert_eq!(page1.baselines[0].version, "v4");
923 assert_eq!(page1.baselines[1].version, "v3");
924
925 let q = ListBaselinesQuery {
926 limit: 2,
927 offset: 4,
928 ..Default::default()
929 };
930 let last = s.list("p", &q).await.unwrap();
931 assert_eq!(last.baselines.len(), 1);
932 assert!(!last.pagination.has_more);
933 assert_eq!(last.baselines[0].version, "v0");
934 }
935
936 #[tokio::test]
937 async fn list_excludes_deleted_and_other_projects() {
938 let s = InMemoryStore::new();
939 s.create(&record_at("p", "b", "v1", day(1))).await.unwrap();
940 s.create(&record_at("p", "b", "v2", day(2))).await.unwrap();
941 s.create(&record_at("other", "b", "v1", day(3)))
942 .await
943 .unwrap();
944 s.delete("p", "b", "v1").await.unwrap();
945
946 let resp = s.list("p", &ListBaselinesQuery::default()).await.unwrap();
947 assert_eq!(resp.pagination.total, 1);
948 assert_eq!(resp.baselines[0].version, "v2");
949 }
950
951 #[tokio::test]
952 async fn list_returns_receipt_in_summary_and_respects_include_receipt_flag() {
953 let s = InMemoryStore::new();
959 s.create(&record_at("p", "b", "v1", day(1))).await.unwrap();
960 let default = s.list("p", &ListBaselinesQuery::default()).await.unwrap();
961 assert!(default.baselines[0].receipt.is_some());
962 let with_receipt = s
963 .list("p", &ListBaselinesQuery::new().with_receipts())
964 .await
965 .unwrap();
966 assert!(with_receipt.baselines[0].receipt.is_some());
967 assert_eq!(
968 default.baselines[0].receipt.as_ref().unwrap().bench.name,
969 with_receipt.baselines[0]
970 .receipt
971 .as_ref()
972 .unwrap()
973 .bench
974 .name
975 );
976 }
977
978 #[tokio::test]
979 async fn list_versions_marks_newest_as_current_and_sorts_desc() {
980 let s = InMemoryStore::new();
981 s.create(&record_at("p", "b", "v1", day(1))).await.unwrap();
982 s.create(&record_at("p", "b", "v3", day(3))).await.unwrap();
983 s.create(&record_at("p", "b", "v2", day(2))).await.unwrap();
984 let versions: Vec<BaselineVersion> = s.list_versions("p", "b").await.unwrap();
985 let names: Vec<_> = versions.iter().map(|v| v.version.clone()).collect();
986 assert_eq!(names, vec!["v3", "v2", "v1"]);
987 assert!(versions[0].is_current);
988 assert!(versions[1..].iter().all(|v| !v.is_current));
989 }
990
991 #[tokio::test]
992 async fn list_versions_skips_deleted_versions() {
993 let s = InMemoryStore::new();
994 s.create(&record_at("p", "b", "v1", day(1))).await.unwrap();
995 s.create(&record_at("p", "b", "v2", day(2))).await.unwrap();
996 s.delete("p", "b", "v2").await.unwrap();
997 let names: Vec<_> = s
998 .list_versions("p", "b")
999 .await
1000 .unwrap()
1001 .into_iter()
1002 .map(|v| v.version)
1003 .collect();
1004 assert_eq!(names, vec!["v1"]);
1005 }
1006
1007 #[tokio::test]
1008 async fn create_verdict_and_list_filters_by_status_and_benchmark() {
1009 let s = InMemoryStore::new();
1010 s.create_verdict(&verdict_at("p", "b1", VerdictStatus::Pass, day(1)))
1011 .await
1012 .unwrap();
1013 s.create_verdict(&verdict_at("p", "b1", VerdictStatus::Fail, day(2)))
1014 .await
1015 .unwrap();
1016 s.create_verdict(&verdict_at("p", "b2", VerdictStatus::Pass, day(3)))
1017 .await
1018 .unwrap();
1019 s.create_verdict(&verdict_at("other", "b1", VerdictStatus::Pass, day(4)))
1020 .await
1021 .unwrap();
1022
1023 let all = s
1025 .list_verdicts("p", &ListVerdictsQuery::default())
1026 .await
1027 .unwrap();
1028 assert_eq!(all.pagination.total, 3);
1029 assert_eq!(all.verdicts[0].benchmark, "b2");
1031
1032 let only_b1 = s
1033 .list_verdicts("p", &ListVerdictsQuery::new().with_benchmark("b1"))
1034 .await
1035 .unwrap();
1036 assert_eq!(only_b1.pagination.total, 2);
1037
1038 let only_fail = s
1039 .list_verdicts(
1040 "p",
1041 &ListVerdictsQuery::new().with_status(VerdictStatus::Fail),
1042 )
1043 .await
1044 .unwrap();
1045 assert_eq!(only_fail.pagination.total, 1);
1046 assert_eq!(only_fail.verdicts[0].benchmark, "b1");
1047 }
1048
1049 #[tokio::test]
1050 async fn list_verdicts_filters_by_time_window_and_paginates() {
1051 let s = InMemoryStore::new();
1052 for i in 0..5 {
1053 s.create_verdict(&verdict_at("p", "b", VerdictStatus::Pass, day(i)))
1054 .await
1055 .unwrap();
1056 }
1057 let q = ListVerdictsQuery {
1058 since: Some(day(1)),
1059 until: Some(day(3)),
1060 ..Default::default()
1061 };
1062 let resp = s.list_verdicts("p", &q).await.unwrap();
1063 assert_eq!(resp.pagination.total, 3);
1064
1065 let q = ListVerdictsQuery::new().with_limit(2).with_offset(2);
1066 let resp = s.list_verdicts("p", &q).await.unwrap();
1067 assert_eq!(resp.verdicts.len(), 2);
1068 assert!(resp.pagination.has_more);
1070 assert_eq!(resp.pagination.total, 5);
1071
1072 let q = ListVerdictsQuery::new().with_limit(2).with_offset(4);
1073 let resp = s.list_verdicts("p", &q).await.unwrap();
1074 assert_eq!(resp.verdicts.len(), 1);
1075 assert!(!resp.pagination.has_more);
1076 }
1077
1078 fn decision_at(
1079 project: &str,
1080 scenario: Option<&str>,
1081 status: MetricStatus,
1082 verdict: VerdictStatus,
1083 accepted_rules: Vec<&str>,
1084 review_required: bool,
1085 ts: chrono::DateTime<Utc>,
1086 ) -> DecisionRecord {
1087 let tradeoff_json = serde_json::json!({
1088 "schema": "perfgate.tradeoff.v1",
1089 "tool": {"name": "perfgate", "version": "0.0.0-test"},
1090 "run": {
1091 "id": format!("run-{}", ts.timestamp_millis()),
1092 "started_at": "2026-01-01T00:00:00Z",
1093 "ended_at": "2026-01-01T00:00:01Z",
1094 "host": {"os": "linux", "arch": "x86_64"}
1095 },
1096 "scenario": scenario.unwrap_or(""),
1097 "configured_rules": [],
1098 "rules": [],
1099 "weighted_deltas": {},
1100 "decision": {
1101 "accepted_tradeoff": !accepted_rules.is_empty(),
1102 "review_required": review_required,
1103 "review_reasons": [],
1104 "status": status.as_str(),
1105 "reason": "test"
1106 },
1107 "verdict": {
1108 "status": verdict.as_str(),
1109 "counts": {"pass": 1, "warn": 0, "fail": 0, "skip": 0},
1110 "reasons": []
1111 }
1112 });
1113 let tradeoff: perfgate_types::TradeoffReceipt =
1114 serde_json::from_value(tradeoff_json).expect("tradeoff fixture must parse");
1115
1116 DecisionRecord {
1117 schema: perfgate_types::baseline_service::DECISION_RECORD_SCHEMA_V1.to_string(),
1118 id: format!("dec-{}", ts.timestamp_millis()),
1119 project: project.to_string(),
1120 scenario: scenario.map(String::from),
1121 status,
1122 verdict,
1123 accepted_rules: accepted_rules.into_iter().map(String::from).collect(),
1124 review_required,
1125 review_reasons: Vec::new(),
1126 git_ref: None,
1127 git_sha: None,
1128 scenario_receipt: None,
1129 tradeoff_receipt: tradeoff,
1130 artifact_index: None,
1131 created_at: ts,
1132 }
1133 }
1134
1135 #[tokio::test]
1136 async fn create_and_latest_decision_returns_max_by_created_at() {
1137 let s = InMemoryStore::new();
1138 for (project, ts) in [
1139 ("p", day(1)),
1140 ("p", day(5)),
1141 ("p", day(3)),
1142 ("other", day(10)),
1143 ] {
1144 s.create_decision(&decision_at(
1145 project,
1146 Some("scn"),
1147 MetricStatus::Pass,
1148 VerdictStatus::Pass,
1149 vec![],
1150 false,
1151 ts,
1152 ))
1153 .await
1154 .unwrap();
1155 }
1156 let latest = s.latest_decision("p").await.unwrap().unwrap();
1157 assert_eq!(latest.created_at, day(5));
1158 }
1159
1160 #[tokio::test]
1161 async fn latest_decision_returns_none_when_empty() {
1162 let s = InMemoryStore::new();
1163 assert!(s.latest_decision("p").await.unwrap().is_none());
1164 }
1165
1166 #[tokio::test]
1167 async fn list_decisions_filters_by_scenario_status_verdict_review_required_accepted_and_rule() {
1168 let s = InMemoryStore::new();
1169 s.create_decision(&decision_at(
1171 "p",
1172 Some("a"),
1173 MetricStatus::Pass,
1174 VerdictStatus::Pass,
1175 vec!["r1"],
1176 false,
1177 day(1),
1178 ))
1179 .await
1180 .unwrap();
1181 s.create_decision(&decision_at(
1183 "p",
1184 Some("b"),
1185 MetricStatus::Warn,
1186 VerdictStatus::Warn,
1187 vec![],
1188 true,
1189 day(2),
1190 ))
1191 .await
1192 .unwrap();
1193 s.create_decision(&decision_at(
1195 "p",
1196 Some("a"),
1197 MetricStatus::Fail,
1198 VerdictStatus::Fail,
1199 vec!["r1", "r2"],
1200 false,
1201 day(3),
1202 ))
1203 .await
1204 .unwrap();
1205
1206 let q = ListDecisionsQuery::new().with_scenario("a");
1207 assert_eq!(s.list_decisions("p", &q).await.unwrap().pagination.total, 2);
1208
1209 let q = ListDecisionsQuery::new().with_status(MetricStatus::Fail);
1210 assert_eq!(s.list_decisions("p", &q).await.unwrap().pagination.total, 1);
1211
1212 let q = ListDecisionsQuery::new().with_verdict(VerdictStatus::Warn);
1213 assert_eq!(s.list_decisions("p", &q).await.unwrap().pagination.total, 1);
1214
1215 let q = ListDecisionsQuery::new().with_review_required(true);
1216 assert_eq!(s.list_decisions("p", &q).await.unwrap().pagination.total, 1);
1217
1218 let q = ListDecisionsQuery::new().with_accepted(true);
1220 assert_eq!(s.list_decisions("p", &q).await.unwrap().pagination.total, 2);
1221
1222 let q = ListDecisionsQuery::new().with_accepted(false);
1223 assert_eq!(s.list_decisions("p", &q).await.unwrap().pagination.total, 1);
1224
1225 let q = ListDecisionsQuery::new().with_rule("r2");
1227 assert_eq!(s.list_decisions("p", &q).await.unwrap().pagination.total, 1);
1228 }
1229
1230 #[tokio::test]
1231 async fn list_decisions_sorts_desc_and_paginates() {
1232 let s = InMemoryStore::new();
1233 for i in 0..4 {
1234 s.create_decision(&decision_at(
1235 "p",
1236 Some("scn"),
1237 MetricStatus::Pass,
1238 VerdictStatus::Pass,
1239 vec![],
1240 false,
1241 day(i),
1242 ))
1243 .await
1244 .unwrap();
1245 }
1246 let q = ListDecisionsQuery {
1247 limit: 2,
1248 offset: 0,
1249 ..Default::default()
1250 };
1251 let page1 = s.list_decisions("p", &q).await.unwrap();
1252 assert!(page1.pagination.has_more);
1253 assert_eq!(page1.decisions[0].created_at, day(3));
1254 assert_eq!(page1.decisions[1].created_at, day(2));
1255 let q = ListDecisionsQuery {
1256 limit: 2,
1257 offset: 2,
1258 ..Default::default()
1259 };
1260 let page2 = s.list_decisions("p", &q).await.unwrap();
1261 assert!(!page2.pagination.has_more);
1262 }
1263
1264 #[tokio::test]
1265 async fn prune_decisions_dry_run_does_not_remove_anything() {
1266 let s = InMemoryStore::new();
1267 s.create_decision(&decision_at(
1268 "p",
1269 Some("a"),
1270 MetricStatus::Pass,
1271 VerdictStatus::Pass,
1272 vec![],
1273 false,
1274 day(1),
1275 ))
1276 .await
1277 .unwrap();
1278 s.create_decision(&decision_at(
1279 "p",
1280 Some("a"),
1281 MetricStatus::Pass,
1282 VerdictStatus::Pass,
1283 vec![],
1284 false,
1285 day(5),
1286 ))
1287 .await
1288 .unwrap();
1289
1290 let resp = s.prune_decisions("p", day(3), true).await.unwrap();
1291 assert_eq!(resp.matched, 1);
1292 assert_eq!(resp.deleted, 0);
1293 assert!(resp.dry_run);
1294 assert_eq!(
1296 s.list_decisions("p", &ListDecisionsQuery::default())
1297 .await
1298 .unwrap()
1299 .pagination
1300 .total,
1301 2
1302 );
1303 }
1304
1305 #[tokio::test]
1306 async fn prune_decisions_removes_older_and_respects_project_scope() {
1307 let s = InMemoryStore::new();
1308 s.create_decision(&decision_at(
1309 "p",
1310 Some("a"),
1311 MetricStatus::Pass,
1312 VerdictStatus::Pass,
1313 vec![],
1314 false,
1315 day(1),
1316 ))
1317 .await
1318 .unwrap();
1319 s.create_decision(&decision_at(
1320 "p",
1321 Some("a"),
1322 MetricStatus::Pass,
1323 VerdictStatus::Pass,
1324 vec![],
1325 false,
1326 day(5),
1327 ))
1328 .await
1329 .unwrap();
1330 s.create_decision(&decision_at(
1332 "other",
1333 Some("a"),
1334 MetricStatus::Pass,
1335 VerdictStatus::Pass,
1336 vec![],
1337 false,
1338 day(1),
1339 ))
1340 .await
1341 .unwrap();
1342
1343 let resp = s.prune_decisions("p", day(3), false).await.unwrap();
1344 assert_eq!(resp.matched, 1);
1345 assert_eq!(resp.deleted, 1);
1346 assert!(!resp.dry_run);
1347 assert_eq!(resp.decision_ids.len(), 1);
1348
1349 assert_eq!(
1351 s.list_decisions("p", &ListDecisionsQuery::default())
1352 .await
1353 .unwrap()
1354 .pagination
1355 .total,
1356 1
1357 );
1358 assert_eq!(
1360 s.list_decisions("other", &ListDecisionsQuery::default())
1361 .await
1362 .unwrap()
1363 .pagination
1364 .total,
1365 1
1366 );
1367 }
1368
1369 #[tokio::test]
1370 async fn audit_log_event_then_list_filters_by_project_actor_action_resource_and_time() {
1371 let s = InMemoryStore::new();
1372 s.log_event(&audit_at(
1373 "p",
1374 "alice",
1375 AuditAction::Create,
1376 AuditResourceType::Baseline,
1377 "v1",
1378 day(1),
1379 ))
1380 .await
1381 .unwrap();
1382 s.log_event(&audit_at(
1383 "p",
1384 "bob",
1385 AuditAction::Delete,
1386 AuditResourceType::Baseline,
1387 "v2",
1388 day(2),
1389 ))
1390 .await
1391 .unwrap();
1392 s.log_event(&audit_at(
1393 "other",
1394 "alice",
1395 AuditAction::Promote,
1396 AuditResourceType::Baseline,
1397 "v3",
1398 day(3),
1399 ))
1400 .await
1401 .unwrap();
1402
1403 let q = ListAuditEventsQuery {
1405 project: Some("p".into()),
1406 ..Default::default()
1407 };
1408 assert_eq!(s.list_events(&q).await.unwrap().pagination.total, 2);
1409
1410 let q = ListAuditEventsQuery {
1412 actor: Some("alice".into()),
1413 ..Default::default()
1414 };
1415 assert_eq!(s.list_events(&q).await.unwrap().pagination.total, 2);
1416
1417 let q = ListAuditEventsQuery {
1419 action: Some("delete".into()),
1420 ..Default::default()
1421 };
1422 assert_eq!(s.list_events(&q).await.unwrap().pagination.total, 1);
1423
1424 let q = ListAuditEventsQuery {
1426 resource_type: Some("baseline".into()),
1427 ..Default::default()
1428 };
1429 assert_eq!(s.list_events(&q).await.unwrap().pagination.total, 3);
1430
1431 let q = ListAuditEventsQuery {
1433 since: Some(day(2)),
1434 until: Some(day(2)),
1435 ..Default::default()
1436 };
1437 assert_eq!(s.list_events(&q).await.unwrap().pagination.total, 1);
1438 }
1439
1440 #[tokio::test]
1441 async fn audit_list_sorts_desc_and_paginates() {
1442 let s = InMemoryStore::new();
1443 for i in 0..5 {
1444 s.log_event(&audit_at(
1445 "p",
1446 "actor",
1447 AuditAction::Create,
1448 AuditResourceType::Baseline,
1449 &format!("v{i}"),
1450 day(i),
1451 ))
1452 .await
1453 .unwrap();
1454 }
1455 let q = ListAuditEventsQuery {
1456 limit: 2,
1457 offset: 0,
1458 ..Default::default()
1459 };
1460 let page = s.list_events(&q).await.unwrap();
1461 assert!(page.pagination.has_more);
1462 assert_eq!(page.events.len(), 2);
1463 assert_eq!(page.events[0].resource_id, "v4");
1464 assert_eq!(page.events[1].resource_id, "v3");
1465 }
1466
1467 #[tokio::test]
1468 async fn backup_restore_smoke_preserves_latest_history_audit_and_dry_run() {
1469 let source = InMemoryStore::new();
1470 source
1471 .create(&record_at("p", "bench", "v1", day(1)))
1472 .await
1473 .unwrap();
1474 source
1475 .create(&record_at("p", "bench", "v2", day(3)))
1476 .await
1477 .unwrap();
1478 source
1479 .create(&record_at("other", "bench", "v1", day(9)))
1480 .await
1481 .unwrap();
1482 source
1483 .create_verdict(&verdict_at("p", "bench", VerdictStatus::Pass, day(2)))
1484 .await
1485 .unwrap();
1486 source
1487 .create_verdict(&verdict_at("p", "bench", VerdictStatus::Warn, day(4)))
1488 .await
1489 .unwrap();
1490 source
1491 .create_decision(&decision_at(
1492 "p",
1493 Some("memory-for-runtime"),
1494 MetricStatus::Pass,
1495 VerdictStatus::Pass,
1496 vec!["runtime_for_memory"],
1497 false,
1498 day(1),
1499 ))
1500 .await
1501 .unwrap();
1502 source
1503 .create_decision(&decision_at(
1504 "p",
1505 Some("memory-for-runtime"),
1506 MetricStatus::Warn,
1507 VerdictStatus::Warn,
1508 vec!["memory_for_runtime"],
1509 true,
1510 day(5),
1511 ))
1512 .await
1513 .unwrap();
1514 source
1515 .log_event(&audit_at(
1516 "p",
1517 "operator",
1518 AuditAction::Create,
1519 AuditResourceType::Decision,
1520 "decision-old",
1521 day(1),
1522 ))
1523 .await
1524 .unwrap();
1525 source
1526 .log_event(&audit_at(
1527 "p",
1528 "operator",
1529 AuditAction::Delete,
1530 AuditResourceType::Decision,
1531 "decision-dry-run",
1532 day(6),
1533 ))
1534 .await
1535 .unwrap();
1536
1537 let baseline_summaries = source
1538 .list("p", &ListBaselinesQuery::default())
1539 .await
1540 .unwrap()
1541 .baselines;
1542 let mut backup_baselines = Vec::new();
1543 for summary in baseline_summaries {
1544 backup_baselines.push(
1545 source
1546 .get("p", &summary.benchmark, &summary.version)
1547 .await
1548 .unwrap()
1549 .expect("listed baseline should resolve to full record"),
1550 );
1551 }
1552 let backup_verdicts = source
1553 .list_verdicts("p", &ListVerdictsQuery::default())
1554 .await
1555 .unwrap()
1556 .verdicts;
1557 let backup_decisions = source
1558 .list_decisions("p", &ListDecisionsQuery::default())
1559 .await
1560 .unwrap()
1561 .decisions;
1562 let backup_audit = source
1563 .list_events(&ListAuditEventsQuery {
1564 project: Some("p".to_string()),
1565 ..Default::default()
1566 })
1567 .await
1568 .unwrap()
1569 .events;
1570
1571 let restored = InMemoryStore::new();
1572 for baseline in &backup_baselines {
1573 restored.create(baseline).await.unwrap();
1574 }
1575 for verdict in &backup_verdicts {
1576 restored.create_verdict(verdict).await.unwrap();
1577 }
1578 for decision in &backup_decisions {
1579 restored.create_decision(decision).await.unwrap();
1580 }
1581 for event in &backup_audit {
1582 restored.log_event(event).await.unwrap();
1583 }
1584
1585 let source_latest_baseline = source.get_latest("p", "bench").await.unwrap().unwrap();
1586 let restored_latest_baseline = restored.get_latest("p", "bench").await.unwrap().unwrap();
1587 assert_eq!(
1588 restored_latest_baseline.version,
1589 source_latest_baseline.version
1590 );
1591 assert_eq!(
1592 restored_latest_baseline.content_hash,
1593 source_latest_baseline.content_hash
1594 );
1595 assert!(
1596 restored
1597 .get_latest("other", "bench")
1598 .await
1599 .unwrap()
1600 .is_none(),
1601 "backup scoped to project p should not restore other projects"
1602 );
1603
1604 let source_verdict_ids: Vec<_> = source
1605 .list_verdicts("p", &ListVerdictsQuery::default())
1606 .await
1607 .unwrap()
1608 .verdicts
1609 .into_iter()
1610 .map(|record| record.id)
1611 .collect();
1612 let restored_verdict_ids: Vec<_> = restored
1613 .list_verdicts("p", &ListVerdictsQuery::default())
1614 .await
1615 .unwrap()
1616 .verdicts
1617 .into_iter()
1618 .map(|record| record.id)
1619 .collect();
1620 assert_eq!(restored_verdict_ids, source_verdict_ids);
1621
1622 let source_latest_decision = source.latest_decision("p").await.unwrap().unwrap();
1623 let restored_latest_decision = restored.latest_decision("p").await.unwrap().unwrap();
1624 assert_eq!(restored_latest_decision.id, source_latest_decision.id);
1625 assert_eq!(
1626 restored_latest_decision.accepted_rules,
1627 source_latest_decision.accepted_rules
1628 );
1629
1630 let source_audit_ids: Vec<_> = source
1631 .list_events(&ListAuditEventsQuery {
1632 project: Some("p".to_string()),
1633 ..Default::default()
1634 })
1635 .await
1636 .unwrap()
1637 .events
1638 .into_iter()
1639 .map(|event| event.id)
1640 .collect();
1641 let restored_audit_ids: Vec<_> = restored
1642 .list_events(&ListAuditEventsQuery {
1643 project: Some("p".to_string()),
1644 ..Default::default()
1645 })
1646 .await
1647 .unwrap()
1648 .events
1649 .into_iter()
1650 .map(|event| event.id)
1651 .collect();
1652 assert_eq!(restored_audit_ids, source_audit_ids);
1653
1654 let prune = restored.prune_decisions("p", day(3), true).await.unwrap();
1655 assert_eq!(prune.matched, 1);
1656 assert_eq!(prune.deleted, 0);
1657 assert!(prune.dry_run);
1658 assert_eq!(
1659 restored
1660 .list_decisions("p", &ListDecisionsQuery::default())
1661 .await
1662 .unwrap()
1663 .pagination
1664 .total,
1665 2,
1666 "dry-run prune must not delete restored decision history"
1667 );
1668 }
1669}