1use crate::application::error::AppError;
25use crate::application::ports::{SyndicResponseRepository, TicketRepository};
26use crate::domain::entities::SyndicResponse;
27use chrono::{DateTime, Utc};
28use std::sync::Arc;
29use uuid::Uuid;
30
31pub struct SyndicResponseUseCases<R, T>
32where
33 R: SyndicResponseRepository,
34 T: TicketRepository,
35{
36 repo: Arc<R>,
37 ticket_repo: Arc<T>,
38}
39
40impl<R, T> SyndicResponseUseCases<R, T>
41where
42 R: SyndicResponseRepository,
43 T: TicketRepository,
44{
45 pub fn new(repo: Arc<R>, ticket_repo: Arc<T>) -> Self {
46 Self { repo, ticket_repo }
47 }
48
49 pub async fn respond(
62 &self,
63 ticket_id: Uuid,
64 syndic_user_id: Uuid,
65 body: String,
66 action_proposed: Option<String>,
67 ) -> Result<SyndicResponse, AppError> {
68 let ticket = self
72 .ticket_repo
73 .find_by_id(ticket_id)
74 .await
75 .map_err(AppError::from)?
76 .ok_or_else(|| AppError::NotFound(format!("ticket {}", ticket_id)))?;
77
78 let response = SyndicResponse::new(ticket_id, syndic_user_id, body, action_proposed)?;
79 self.repo.save(&response).await?;
80
81 let now = Utc::now();
84 if let Some(due) = ticket.sla_due_at {
85 if ticket.sla_escalated_at.is_none() && now < due {
86 self.repo.mark_ticket_escalated(ticket_id, now).await?;
91 }
92 }
93
94 Ok(response)
95 }
96
97 pub async fn list_for_ticket(&self, ticket_id: Uuid) -> Result<Vec<SyndicResponse>, AppError> {
98 self.repo.list_for_ticket(ticket_id).await
99 }
100
101 pub async fn escalate_overdue(&self, now: DateTime<Utc>) -> Result<Vec<Uuid>, AppError> {
109 let overdue = self.repo.find_overdue_tickets(now).await?;
110 let mut escalated = Vec::with_capacity(overdue.len());
111 for id in overdue {
112 self.repo.mark_ticket_escalated(id, now).await?;
113 escalated.push(id);
114 }
115 Ok(escalated)
116 }
117}
118
119#[cfg(test)]
124mod tests {
125 use super::*;
126 use crate::application::ports::TicketRepository;
127 use crate::domain::entities::{
128 Ticket, TicketCategory, TicketKind, TicketPriority, TicketSeverity, TicketStatus,
129 };
130 use async_trait::async_trait;
131 use chrono::Duration;
132 use std::collections::HashMap;
133 use std::sync::Mutex;
134
135 #[derive(Default)]
138 struct InMemorySyndicResponseRepo {
139 rows: Mutex<Vec<SyndicResponse>>,
140 escalated: Mutex<HashMap<Uuid, DateTime<Utc>>>,
143 due_tickets: Mutex<HashMap<Uuid, DateTime<Utc>>>,
146 }
147
148 #[async_trait]
149 impl SyndicResponseRepository for InMemorySyndicResponseRepo {
150 async fn save(&self, response: &SyndicResponse) -> Result<(), AppError> {
151 self.rows.lock().unwrap().push(response.clone());
152 Ok(())
153 }
154
155 async fn list_for_ticket(&self, ticket_id: Uuid) -> Result<Vec<SyndicResponse>, AppError> {
156 let mut out: Vec<SyndicResponse> = self
157 .rows
158 .lock()
159 .unwrap()
160 .iter()
161 .filter(|r| r.ticket_id == ticket_id)
162 .cloned()
163 .collect();
164 out.sort_by_key(|r| r.created_at);
165 Ok(out)
166 }
167
168 async fn find_overdue_tickets(&self, now: DateTime<Utc>) -> Result<Vec<Uuid>, AppError> {
169 let due = self.due_tickets.lock().unwrap();
170 let escalated = self.escalated.lock().unwrap();
171 Ok(due
172 .iter()
173 .filter(|(id, deadline)| **deadline <= now && !escalated.contains_key(id))
174 .map(|(id, _)| *id)
175 .collect())
176 }
177
178 async fn mark_ticket_escalated(
179 &self,
180 ticket_id: Uuid,
181 escalated_at: DateTime<Utc>,
182 ) -> Result<(), AppError> {
183 self.escalated
185 .lock()
186 .unwrap()
187 .entry(ticket_id)
188 .or_insert(escalated_at);
189 Ok(())
190 }
191 }
192
193 struct InMemoryTicketRepo {
196 tickets: Mutex<HashMap<Uuid, Ticket>>,
197 }
198
199 impl InMemoryTicketRepo {
200 fn new() -> Self {
201 Self {
202 tickets: Mutex::new(HashMap::new()),
203 }
204 }
205
206 fn insert(&self, t: Ticket) {
207 self.tickets.lock().unwrap().insert(t.id, t);
208 }
209 }
210
211 #[async_trait]
212 impl TicketRepository for InMemoryTicketRepo {
213 async fn create(&self, ticket: &Ticket) -> Result<Ticket, String> {
214 self.tickets
215 .lock()
216 .unwrap()
217 .insert(ticket.id, ticket.clone());
218 Ok(ticket.clone())
219 }
220 async fn find_by_id(&self, id: Uuid) -> Result<Option<Ticket>, String> {
221 Ok(self.tickets.lock().unwrap().get(&id).cloned())
222 }
223 async fn find_by_building(&self, _: Uuid) -> Result<Vec<Ticket>, String> {
224 Ok(vec![])
225 }
226 async fn find_by_organization(&self, _: Uuid) -> Result<Vec<Ticket>, String> {
227 Ok(vec![])
228 }
229 async fn find_by_created_by(&self, _: Uuid) -> Result<Vec<Ticket>, String> {
230 Ok(vec![])
231 }
232 async fn find_by_assigned_to(&self, _: Uuid) -> Result<Vec<Ticket>, String> {
233 Ok(vec![])
234 }
235 async fn find_by_status(&self, _: Uuid, _: TicketStatus) -> Result<Vec<Ticket>, String> {
236 Ok(vec![])
237 }
238 async fn update(&self, ticket: &Ticket) -> Result<Ticket, String> {
239 self.tickets
240 .lock()
241 .unwrap()
242 .insert(ticket.id, ticket.clone());
243 Ok(ticket.clone())
244 }
245 async fn delete(&self, id: Uuid) -> Result<bool, String> {
246 Ok(self.tickets.lock().unwrap().remove(&id).is_some())
247 }
248 async fn count_by_building(&self, _: Uuid) -> Result<i64, String> {
249 Ok(0)
250 }
251 async fn count_by_status(&self, _: Uuid, _: TicketStatus) -> Result<i64, String> {
252 Ok(0)
253 }
254 async fn count_by_organization(&self, _: Uuid) -> Result<i64, String> {
255 Ok(0)
256 }
257 async fn count_by_organization_and_status(
258 &self,
259 _: Uuid,
260 _: TicketStatus,
261 ) -> Result<i64, String> {
262 Ok(0)
263 }
264 }
265
266 fn make_use_cases() -> (
269 Arc<InMemorySyndicResponseRepo>,
270 Arc<InMemoryTicketRepo>,
271 SyndicResponseUseCases<InMemorySyndicResponseRepo, InMemoryTicketRepo>,
272 ) {
273 let resp_repo = Arc::new(InMemorySyndicResponseRepo::default());
274 let ticket_repo = Arc::new(InMemoryTicketRepo::new());
275 let uc = SyndicResponseUseCases::new(resp_repo.clone(), ticket_repo.clone());
276 (resp_repo, ticket_repo, uc)
277 }
278
279 fn make_complaint_ticket(severity: TicketSeverity) -> Ticket {
280 Ticket::new_with_kind(
281 Uuid::new_v4(),
282 Uuid::new_v4(),
283 None,
284 Uuid::new_v4(),
285 "Plainte tapage".to_string(),
286 "Description suffisamment longue".to_string(),
287 TicketCategory::Other,
288 TicketPriority::High,
289 TicketKind::Complaint,
290 Some(severity),
291 None,
292 Vec::new(),
293 Vec::new(),
294 )
295 .expect("complaint must be valid in fixture")
296 }
297
298 fn fixture_body() -> String {
299 "Bonjour, devis demandé chez le prestataire.".to_string()
300 }
301
302 #[tokio::test]
305 async fn happy_respond_before_sla_due_marks_ticket_as_escalated() {
306 let (resp_repo, ticket_repo, uc) = make_use_cases();
307 let ticket = make_complaint_ticket(TicketSeverity::Critical);
308 let ticket_id = ticket.id;
309 ticket_repo.insert(ticket);
310
311 let response = uc
312 .respond(ticket_id, Uuid::new_v4(), fixture_body(), None)
313 .await
314 .expect("respond should succeed");
315
316 assert_eq!(response.ticket_id, ticket_id);
317 assert_eq!(resp_repo.rows.lock().unwrap().len(), 1);
319 assert!(
321 resp_repo.escalated.lock().unwrap().contains_key(&ticket_id),
322 "respond inside SLA window must pre-empt future cron escalation"
323 );
324 }
325
326 #[tokio::test]
327 async fn happy_list_for_ticket_returns_responses_oldest_first() {
328 let (_resp_repo, ticket_repo, uc) = make_use_cases();
329 let ticket = make_complaint_ticket(TicketSeverity::Normal);
330 let ticket_id = ticket.id;
331 ticket_repo.insert(ticket);
332
333 uc.respond(
334 ticket_id,
335 Uuid::new_v4(),
336 "Premier message du syndic.".to_string(),
337 None,
338 )
339 .await
340 .unwrap();
341 uc.respond(
344 ticket_id,
345 Uuid::new_v4(),
346 "Deuxième mise à jour du syndic.".to_string(),
347 Some("schedule_inspection".to_string()),
348 )
349 .await
350 .unwrap();
351
352 let listed = uc.list_for_ticket(ticket_id).await.unwrap();
353 assert_eq!(listed.len(), 2);
354 assert!(listed[0].created_at <= listed[1].created_at);
355 }
356
357 #[tokio::test]
360 async fn edge_respond_after_sla_due_does_not_pre_empt_escalation() {
361 let (resp_repo, ticket_repo, uc) = make_use_cases();
362 let mut ticket = make_complaint_ticket(TicketSeverity::Critical);
363 ticket.sla_due_at = Some(Utc::now() - Duration::seconds(10));
365 let ticket_id = ticket.id;
366 ticket_repo.insert(ticket);
367
368 let _ = uc
369 .respond(ticket_id, Uuid::new_v4(), fixture_body(), None)
370 .await
371 .expect("respond past deadline still records the reply");
372
373 assert!(
375 !resp_repo.escalated.lock().unwrap().contains_key(&ticket_id),
376 "late response must NOT touch sla_escalated_at"
377 );
378 }
379
380 #[tokio::test]
381 async fn edge_respond_on_ticket_without_sla_due_succeeds_and_no_escalation() {
382 let (resp_repo, ticket_repo, uc) = make_use_cases();
383 let t = Ticket::new(
385 Uuid::new_v4(),
386 Uuid::new_v4(),
387 None,
388 Uuid::new_v4(),
389 "Title".to_string(),
390 "Description".to_string(),
391 TicketCategory::Other,
392 TicketPriority::Low,
393 )
394 .unwrap();
395 let ticket_id = t.id;
396 ticket_repo.insert(t);
397
398 uc.respond(ticket_id, Uuid::new_v4(), fixture_body(), None)
399 .await
400 .unwrap();
401
402 assert!(!resp_repo.escalated.lock().unwrap().contains_key(&ticket_id));
403 }
404
405 #[tokio::test]
408 async fn security_escalate_overdue_is_idempotent_on_already_escalated_tickets() {
409 let (resp_repo, _ticket_repo, uc) = make_use_cases();
410
411 let t1 = Uuid::new_v4();
412 let t2 = Uuid::new_v4();
413 let now = Utc::now();
414
415 resp_repo
417 .due_tickets
418 .lock()
419 .unwrap()
420 .insert(t1, now - Duration::minutes(5));
421 resp_repo
422 .due_tickets
423 .lock()
424 .unwrap()
425 .insert(t2, now - Duration::hours(1));
426
427 let first_pass = uc.escalate_overdue(now).await.unwrap();
428 assert_eq!(first_pass.len(), 2, "first pass should escalate both");
429
430 let second_pass = uc.escalate_overdue(now).await.unwrap();
432 assert!(
433 second_pass.is_empty(),
434 "second pass must be a no-op (idempotency)"
435 );
436 }
437
438 #[tokio::test]
441 async fn negative_respond_on_unknown_ticket_returns_not_found() {
442 let (_resp_repo, _ticket_repo, uc) = make_use_cases();
443 let err = uc
444 .respond(Uuid::new_v4(), Uuid::new_v4(), fixture_body(), None)
445 .await
446 .unwrap_err();
447 assert!(matches!(err, AppError::NotFound(_)));
448 }
449
450 #[tokio::test]
451 async fn negative_respond_with_short_body_is_rejected_at_entity_level() {
452 let (_resp_repo, ticket_repo, uc) = make_use_cases();
453 let ticket = make_complaint_ticket(TicketSeverity::Normal);
454 let ticket_id = ticket.id;
455 ticket_repo.insert(ticket);
456
457 let err = uc
458 .respond(ticket_id, Uuid::new_v4(), "ko".to_string(), None)
459 .await
460 .unwrap_err();
461 assert!(matches!(err, AppError::Validation(_)));
462 }
463}