use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::validation::{Validate, ValidationError};
pub const MAX_BATCH_SIZE: usize = 100;
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct BatchActionRequest {
pub action: String,
pub ids: Vec<Uuid>,
}
impl Validate for BatchActionRequest {
fn validate(&self) -> Result<(), ValidationError> {
if self.action.is_empty() {
return Err(ValidationError {
field: "action",
message: "action must not be empty".to_string(),
});
}
if self.ids.is_empty() {
return Err(ValidationError {
field: "ids",
message: "ids must not be empty".to_string(),
});
}
if self.ids.len() > MAX_BATCH_SIZE {
return Err(ValidationError {
field: "ids",
message: format!("ids must contain at most {MAX_BATCH_SIZE} entries"),
});
}
Ok(())
}
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct BatchActionResponse {
pub succeeded: Vec<BatchActionSuccess>,
pub failed: Vec<BatchActionFailure>,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct BatchActionSuccess {
pub id: Uuid,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct BatchActionFailure {
pub id: Uuid,
pub error: String,
}
#[cfg(test)]
mod tests {
#![expect(
clippy::assertions_on_result_states,
reason = "test assertions — is_ok/is_err provides readable failure messages"
)]
use super::*;
#[test]
fn validate_rejects_empty_action() {
let req = BatchActionRequest {
action: String::new(),
ids: vec![Uuid::nil()],
};
let err = req.validate().unwrap_err();
assert_eq!(err.field, "action");
}
#[test]
fn validate_rejects_empty_ids() {
let req = BatchActionRequest {
action: "approve".to_string(),
ids: vec![],
};
let err = req.validate().unwrap_err();
assert_eq!(err.field, "ids");
}
#[test]
fn validate_rejects_over_max_ids() {
let ids: Vec<Uuid> = (0..=MAX_BATCH_SIZE).map(|_| Uuid::new_v4()).collect();
let req = BatchActionRequest {
action: "approve".to_string(),
ids,
};
let err = req.validate().unwrap_err();
assert_eq!(err.field, "ids");
assert!(err.message.contains(&MAX_BATCH_SIZE.to_string()));
}
#[test]
fn validate_accepts_valid_request() {
let req = BatchActionRequest {
action: "approve".to_string(),
ids: vec![Uuid::nil()],
};
assert!(req.validate().is_ok());
}
#[test]
fn validate_accepts_max_ids() {
let ids: Vec<Uuid> = (0..MAX_BATCH_SIZE).map(|_| Uuid::new_v4()).collect();
let req = BatchActionRequest {
action: "delete".to_string(),
ids,
};
assert!(req.validate().is_ok());
}
#[test]
fn batch_action_request_round_trip() {
let req = BatchActionRequest {
action: "approve".to_string(),
ids: vec![Uuid::nil()],
};
let json = serde_json::to_string(&req).expect("serialize");
let parsed: BatchActionRequest = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed.action, "approve");
assert_eq!(parsed.ids.len(), 1);
}
#[test]
fn batch_action_response_round_trip() {
let resp = BatchActionResponse {
succeeded: vec![BatchActionSuccess { id: Uuid::nil() }],
failed: vec![BatchActionFailure {
id: Uuid::nil(),
error: "not found".to_string(),
}],
};
let json = serde_json::to_string(&resp).expect("serialize");
let parsed: BatchActionResponse = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed.succeeded.len(), 1);
assert_eq!(parsed.failed.len(), 1);
assert_eq!(parsed.failed[0].error, "not found");
}
}