use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ApprovalRequirement {
#[serde(default)]
pub reason: Option<String>,
pub required_approvers: u32,
#[serde(default)]
pub approver_groups: Vec<String>,
}
impl Default for ApprovalRequirement {
fn default() -> Self {
Self {
reason: None,
required_approvers: 1,
approver_groups: Vec::new(),
}
}
}
impl ApprovalRequirement {
pub fn is_satisfied_by(&self, received: usize) -> bool {
received >= self.required_approvers as usize
}
pub fn allows_everyone(&self) -> bool {
self.approver_groups.is_empty()
}
}
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StepApproval {
pub user_id: Uuid,
pub approved_by: String,
pub at: DateTime<Utc>,
}
#[cfg(test)]
mod tests {
use serde_json::{from_str, from_value, json, to_string};
use super::*;
fn requirement(required: u32) -> ApprovalRequirement {
ApprovalRequirement {
reason: Some("production deploy".to_string()),
required_approvers: required,
approver_groups: vec!["sre".to_string()],
}
}
#[test]
fn requirement_serde_roundtrip() {
let req = requirement(3);
let json = to_string(&req).expect("serialize");
let back: ApprovalRequirement = from_str(&json).expect("deserialize");
assert_eq!(back, req);
}
#[test]
fn requirement_defaults_missing_reason_and_groups() {
let back: ApprovalRequirement =
from_value(json!({"required_approvers": 1})).expect("deserialize");
assert_eq!(back, ApprovalRequirement::default());
}
#[test]
fn requirement_written_by_approval_rules_still_deserializes() {
let back: ApprovalRequirement = from_value(json!({
"rule_index": 0,
"condition": "payload.amount > 10000",
"required_approvers": 2,
"approver_groups": ["finance"],
"evaluated": [{"index": 0, "condition": "payload.amount > 10000", "matched": true}]
}))
.expect("deserialize");
assert_eq!(back.reason, None);
assert_eq!(back.required_approvers, 2);
assert_eq!(back.approver_groups, vec!["finance"]);
}
#[test]
fn default_requires_one_approval_from_anyone() {
let req = ApprovalRequirement::default();
assert_eq!(req.reason, None);
assert_eq!(req.required_approvers, 1);
assert!(req.approver_groups.is_empty());
assert!(req.allows_everyone());
}
#[test]
fn is_satisfied_by_boundaries() {
let req = requirement(3);
assert!(!req.is_satisfied_by(0));
assert!(!req.is_satisfied_by(2));
assert!(req.is_satisfied_by(3));
assert!(req.is_satisfied_by(4));
assert!(!req.allows_everyone());
}
#[test]
fn step_approval_serde_roundtrip() {
let vote = StepApproval {
user_id: Uuid::now_v7(),
approved_by: "élodie".to_string(),
at: Utc::now(),
};
let json = to_string(&vote).expect("serialize");
let back: StepApproval = from_str(&json).expect("deserialize");
assert_eq!(back, vote);
}
}