koprogo_api/domain/copropriete/
syndic_response.rs1use crate::application::error::AppError;
26use crate::domain::entities::TicketSeverity;
27use chrono::{DateTime, Duration, Utc};
28use serde::{Deserialize, Serialize};
29use uuid::Uuid;
30
31pub const MIN_RESPONSE_BODY_LEN: usize = 10;
33
34pub const MAX_RESPONSE_BODY_LEN: usize = 5000;
36
37pub const ALLOWED_ACTIONS: &[&str] = &[
41 "schedule_inspection",
42 "request_quote",
43 "closed_no_action",
44 "escalated_board",
45 "other",
46];
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
49pub struct SyndicResponse {
50 pub id: Uuid,
51 pub ticket_id: Uuid,
52 pub syndic_user_id: Uuid,
53 pub body: String,
54 pub action_proposed: Option<String>,
55 pub created_at: DateTime<Utc>,
56}
57
58impl SyndicResponse {
59 pub fn new(
68 ticket_id: Uuid,
69 syndic_user_id: Uuid,
70 body: String,
71 action_proposed: Option<String>,
72 ) -> Result<Self, AppError> {
73 let trimmed_body = body.trim().to_string();
74 if trimmed_body.len() < MIN_RESPONSE_BODY_LEN {
75 return Err(AppError::Validation(format!(
76 "SyndicResponse body must be at least {} chars",
77 MIN_RESPONSE_BODY_LEN
78 )));
79 }
80 if trimmed_body.len() > MAX_RESPONSE_BODY_LEN {
81 return Err(AppError::Validation(format!(
82 "SyndicResponse body must be at most {} chars",
83 MAX_RESPONSE_BODY_LEN
84 )));
85 }
86 if let Some(ref action) = action_proposed {
87 let normalised = action.trim().to_lowercase();
88 if !ALLOWED_ACTIONS.contains(&normalised.as_str()) {
89 return Err(AppError::Validation(format!(
90 "Invalid action_proposed: {} (allowed: {:?})",
91 action, ALLOWED_ACTIONS
92 )));
93 }
94 }
95 if ticket_id.is_nil() || syndic_user_id.is_nil() {
96 return Err(AppError::Validation(
97 "SyndicResponse references must not be nil UUIDs".to_string(),
98 ));
99 }
100
101 Ok(Self {
102 id: Uuid::new_v4(),
103 ticket_id,
104 syndic_user_id,
105 body: trimmed_body,
106 action_proposed: action_proposed.map(|a| a.trim().to_lowercase()),
107 created_at: Utc::now(),
108 })
109 }
110}
111
112pub fn sla_window_for_severity(severity: TicketSeverity) -> Duration {
119 match severity {
120 TicketSeverity::Critical => Duration::hours(24),
121 TicketSeverity::High => Duration::hours(72),
122 TicketSeverity::Normal => Duration::days(5),
123 TicketSeverity::Low => Duration::days(10),
124 }
125}
126
127#[cfg(test)]
132mod tests {
133 use super::*;
134
135 fn fixture_ids() -> (Uuid, Uuid) {
136 (Uuid::new_v4(), Uuid::new_v4())
137 }
138
139 fn fixture_body() -> String {
140 "Bonjour, j'ai bien noté votre plainte, devis demandé.".to_string()
141 }
142
143 #[test]
146 fn happy_create_response_with_minimal_body_and_no_action() {
147 let (ticket_id, syndic) = fixture_ids();
148 let body = "0123456789".to_string();
150 let r = SyndicResponse::new(ticket_id, syndic, body.clone(), None)
151 .expect("10-char body without action must succeed");
152 assert_eq!(r.ticket_id, ticket_id);
153 assert_eq!(r.syndic_user_id, syndic);
154 assert_eq!(r.body, body);
155 assert!(r.action_proposed.is_none());
156 }
157
158 #[test]
159 fn happy_create_response_with_allowed_action() {
160 let (ticket_id, syndic) = fixture_ids();
161 let r = SyndicResponse::new(
162 ticket_id,
163 syndic,
164 fixture_body(),
165 Some("schedule_inspection".to_string()),
166 )
167 .expect("allowed action must succeed");
168 assert_eq!(r.action_proposed.as_deref(), Some("schedule_inspection"));
169 }
170
171 #[test]
172 fn happy_sla_window_critical_is_24_hours() {
173 assert_eq!(
174 sla_window_for_severity(TicketSeverity::Critical),
175 Duration::hours(24)
176 );
177 }
178
179 #[test]
180 fn happy_sla_window_low_is_ten_days() {
181 assert_eq!(
182 sla_window_for_severity(TicketSeverity::Low),
183 Duration::days(10)
184 );
185 }
186
187 #[test]
188 fn happy_sla_window_high_is_72_hours_and_normal_is_5_days() {
189 assert_eq!(
190 sla_window_for_severity(TicketSeverity::High),
191 Duration::hours(72)
192 );
193 assert_eq!(
194 sla_window_for_severity(TicketSeverity::Normal),
195 Duration::days(5)
196 );
197 }
198
199 #[test]
200 fn happy_sla_windows_strictly_decrease_with_severity() {
201 let critical = sla_window_for_severity(TicketSeverity::Critical);
203 let high = sla_window_for_severity(TicketSeverity::High);
204 let normal = sla_window_for_severity(TicketSeverity::Normal);
205 let low = sla_window_for_severity(TicketSeverity::Low);
206 assert!(critical < high);
207 assert!(high < normal);
208 assert!(normal < low);
209 }
210
211 #[test]
214 fn edge_body_exactly_min_len_is_accepted() {
215 let (ticket_id, syndic) = fixture_ids();
216 let body = "X".repeat(MIN_RESPONSE_BODY_LEN);
217 assert!(SyndicResponse::new(ticket_id, syndic, body, None).is_ok());
218 }
219
220 #[test]
221 fn edge_body_exactly_max_len_is_accepted() {
222 let (ticket_id, syndic) = fixture_ids();
223 let body = "X".repeat(MAX_RESPONSE_BODY_LEN);
224 assert!(SyndicResponse::new(ticket_id, syndic, body, None).is_ok());
225 }
226
227 #[test]
228 fn edge_body_one_under_min_is_rejected() {
229 let (ticket_id, syndic) = fixture_ids();
230 let body = "X".repeat(MIN_RESPONSE_BODY_LEN - 1);
231 let err = SyndicResponse::new(ticket_id, syndic, body, None).unwrap_err();
232 assert!(matches!(err, AppError::Validation(_)));
233 }
234
235 #[test]
236 fn edge_body_one_over_max_is_rejected() {
237 let (ticket_id, syndic) = fixture_ids();
238 let body = "X".repeat(MAX_RESPONSE_BODY_LEN + 1);
239 let err = SyndicResponse::new(ticket_id, syndic, body, None).unwrap_err();
240 assert!(matches!(err, AppError::Validation(_)));
241 }
242
243 #[test]
244 fn edge_action_normalised_to_lowercase() {
245 let (ticket_id, syndic) = fixture_ids();
246 let r = SyndicResponse::new(
247 ticket_id,
248 syndic,
249 fixture_body(),
250 Some(" Request_Quote ".to_string()),
251 )
252 .expect("whitespace + uppercase should be normalised");
253 assert_eq!(r.action_proposed.as_deref(), Some("request_quote"));
254 }
255
256 #[test]
259 fn security_unknown_action_is_rejected_no_smuggling() {
260 let (ticket_id, syndic) = fixture_ids();
261 let err = SyndicResponse::new(
262 ticket_id,
263 syndic,
264 fixture_body(),
265 Some("rm_minus_rf".to_string()),
266 )
267 .unwrap_err();
268 assert!(matches!(err, AppError::Validation(_)));
269 }
270
271 #[test]
272 fn security_nil_ticket_id_is_rejected() {
273 let err =
274 SyndicResponse::new(Uuid::nil(), Uuid::new_v4(), fixture_body(), None).unwrap_err();
275 assert!(matches!(err, AppError::Validation(_)));
276 }
277
278 #[test]
279 fn security_nil_syndic_id_is_rejected() {
280 let err =
281 SyndicResponse::new(Uuid::new_v4(), Uuid::nil(), fixture_body(), None).unwrap_err();
282 assert!(matches!(err, AppError::Validation(_)));
283 }
284
285 #[test]
288 fn negative_empty_body_is_rejected() {
289 let (ticket_id, syndic) = fixture_ids();
290 let err = SyndicResponse::new(ticket_id, syndic, String::new(), None).unwrap_err();
291 assert!(matches!(err, AppError::Validation(_)));
292 }
293
294 #[test]
295 fn negative_whitespace_only_body_is_rejected() {
296 let (ticket_id, syndic) = fixture_ids();
297 let err = SyndicResponse::new(ticket_id, syndic, " ".to_string(), None).unwrap_err();
298 assert!(matches!(err, AppError::Validation(_)));
299 }
300
301 #[test]
302 fn negative_short_body_five_chars_is_rejected() {
303 let (ticket_id, syndic) = fixture_ids();
304 let err = SyndicResponse::new(ticket_id, syndic, "short".to_string(), None).unwrap_err();
305 assert!(matches!(err, AppError::Validation(_)));
306 }
307}