use std::time::Duration;
use ironflow_store::entities::Assignee;
use serde::{Deserialize, Serialize};
use super::EscalationPolicy;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalConfig {
message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
timeout_seconds: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
deadline_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
on_timeout: Option<EscalationPolicy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
assignee: Option<Assignee>,
}
impl ApprovalConfig {
pub fn new(message: &str) -> Self {
Self {
message: message.to_string(),
timeout_seconds: None,
deadline_secs: None,
on_timeout: None,
assignee: None,
}
}
pub fn with_timeout_seconds(mut self, seconds: u64) -> Self {
self.timeout_seconds = Some(seconds);
self
}
pub fn with_deadline(self, deadline: Duration) -> Self {
self.with_deadline_secs(deadline.as_secs())
}
pub fn with_deadline_secs(mut self, secs: u64) -> Self {
assert!(secs > 0, "approval deadline must be greater than zero");
self.deadline_secs = Some(secs);
self
}
pub fn on_timeout(mut self, policy: EscalationPolicy) -> Self {
self.on_timeout = Some(policy);
self
}
pub fn assigned_to(mut self, assignee: Assignee) -> Self {
assert!(
!assignee.name().trim().is_empty(),
"approval assignee must not be empty"
);
self.assignee = Some(assignee);
self
}
pub fn message(&self) -> &str {
&self.message
}
pub fn timeout_seconds(&self) -> Option<u64> {
self.timeout_seconds
}
pub fn deadline(&self) -> Option<Duration> {
self.deadline_secs.map(Duration::from_secs)
}
pub fn deadline_secs(&self) -> Option<u64> {
self.deadline_secs
}
pub fn on_timeout_policy(&self) -> Option<&EscalationPolicy> {
self.on_timeout.as_ref()
}
pub fn assignee(&self) -> Option<&Assignee> {
self.assignee.as_ref()
}
pub fn effective_deadline_secs(&self) -> Option<u64> {
self.deadline_secs.or(self.timeout_seconds)
}
pub fn effective_policy(&self) -> EscalationPolicy {
self.on_timeout
.clone()
.unwrap_or(EscalationPolicy::AutoReject)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::NotificationTarget;
#[test]
fn new_sets_message() {
let config = ApprovalConfig::new("Deploy?");
assert_eq!(config.message(), "Deploy?");
assert!(config.timeout_seconds().is_none());
assert!(config.deadline_secs().is_none());
assert!(config.on_timeout_policy().is_none());
assert!(config.assignee().is_none());
}
#[test]
fn with_timeout() {
let config = ApprovalConfig::new("Approve?").with_timeout_seconds(7200);
assert_eq!(config.timeout_seconds(), Some(7200));
}
#[test]
fn with_deadline_stores_whole_seconds() {
let config = ApprovalConfig::new("Approve?").with_deadline(Duration::from_millis(90_500));
assert_eq!(config.deadline_secs(), Some(90));
assert_eq!(config.deadline(), Some(Duration::from_secs(90)));
}
#[test]
fn on_timeout_stores_the_policy() {
let config = ApprovalConfig::new("Approve?").on_timeout(EscalationPolicy::AutoApprove);
assert_eq!(
config.on_timeout_policy(),
Some(&EscalationPolicy::AutoApprove)
);
}
#[test]
fn assigned_to_stores_the_assignee() {
let config =
ApprovalConfig::new("Approve?").assigned_to(Assignee::group("release-managers"));
assert_eq!(
config.assignee(),
Some(&Assignee::group("release-managers"))
);
}
#[test]
fn effective_deadline_prefers_the_explicit_deadline() {
let config = ApprovalConfig::new("Approve?")
.with_timeout_seconds(7200)
.with_deadline_secs(60);
assert_eq!(config.effective_deadline_secs(), Some(60));
}
#[test]
fn effective_deadline_falls_back_to_the_legacy_timeout() {
let config = ApprovalConfig::new("Approve?").with_timeout_seconds(7200);
assert_eq!(config.effective_deadline_secs(), Some(7200));
}
#[test]
fn effective_deadline_is_none_without_any_timer() {
assert_eq!(
ApprovalConfig::new("Approve?").effective_deadline_secs(),
None
);
}
#[test]
fn effective_policy_defaults_to_auto_reject() {
let config = ApprovalConfig::new("Approve?").with_deadline_secs(60);
assert_eq!(config.effective_policy(), EscalationPolicy::AutoReject);
}
#[test]
fn effective_policy_returns_the_configured_policy() {
let policy = EscalationPolicy::Chain(vec![
EscalationPolicy::Notify(vec![NotificationTarget::Webhook {
url: "https://example.com/sla".to_string(),
}]),
EscalationPolicy::AutoReject,
]);
let config = ApprovalConfig::new("Approve?")
.with_deadline_secs(60)
.on_timeout(policy.clone());
assert_eq!(config.effective_policy(), policy);
}
#[test]
#[should_panic(expected = "approval deadline must be greater than zero")]
fn with_deadline_secs_rejects_zero() {
let _ = ApprovalConfig::new("Approve?").with_deadline_secs(0);
}
#[test]
#[should_panic(expected = "approval assignee must not be empty")]
fn assigned_to_rejects_blank() {
let _ = ApprovalConfig::new("Approve?").assigned_to(Assignee::group(" "));
}
#[test]
fn serde_roundtrip() {
let config = ApprovalConfig::new("Deploy to prod?")
.with_timeout_seconds(3600)
.with_deadline_secs(1800)
.on_timeout(EscalationPolicy::Escalate(Assignee::group("sre-oncall")))
.assigned_to(Assignee::group("release-managers"));
let json = serde_json::to_string(&config).expect("serialize");
let back: ApprovalConfig = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.message(), config.message());
assert_eq!(back.timeout_seconds(), config.timeout_seconds());
assert_eq!(back.deadline_secs(), config.deadline_secs());
assert_eq!(back.on_timeout_policy(), config.on_timeout_policy());
assert_eq!(back.assignee(), config.assignee());
}
#[test]
fn serde_minimal() {
let config = ApprovalConfig::new("Approve?");
let json = serde_json::to_string(&config).expect("serialize");
assert!(!json.contains("timeout_seconds"));
assert!(!json.contains("deadline_secs"));
assert!(!json.contains("on_timeout"));
assert!(!json.contains("assignee"));
}
#[test]
fn serde_accepts_a_config_written_before_escalation_existed() {
let config: ApprovalConfig =
serde_json::from_str(r#"{"message":"Approve?","timeout_seconds":60}"#)
.expect("deserialize");
assert_eq!(config.effective_deadline_secs(), Some(60));
assert_eq!(config.effective_policy(), EscalationPolicy::AutoReject);
}
}