1use crate::application::dto::{
2 AssignTicketRequest, CancelTicketRequest, CreateTicketRequest, ReopenTicketRequest,
3 ResolveTicketRequest, TicketResponse, UpdateTicketRequest,
4};
5use crate::application::error::AppError;
6use crate::application::ports::TicketRepository;
7use crate::domain::entities::{Ticket, TicketKind, TicketStatus};
8use std::sync::Arc;
9use uuid::Uuid;
10
11pub struct TicketUseCases {
12 ticket_repository: Arc<dyn TicketRepository>,
13}
14
15impl TicketUseCases {
16 pub fn new(ticket_repository: Arc<dyn TicketRepository>) -> Self {
17 Self { ticket_repository }
18 }
19
20 pub async fn create_ticket(
27 &self,
28 organization_id: Uuid,
29 created_by: Uuid,
30 request: CreateTicketRequest,
31 ) -> Result<TicketResponse, String> {
32 let kind = request.kind.unwrap_or(TicketKind::Request);
33
34 let ticket = Ticket::new_with_kind(
38 organization_id,
39 request.building_id,
40 request.unit_id,
41 created_by,
42 request.title,
43 request.description,
44 request.category,
45 request.priority,
46 kind,
47 request.severity,
48 request.incident_date,
49 request.evidence_attachments,
50 request.witnesses,
51 )
52 .map_err(|e| e.to_string())?;
56
57 let created = self.ticket_repository.create(&ticket).await?;
58 Ok(TicketResponse::from(created))
59 }
60
61 pub async fn update_ticket_fields(
74 &self,
75 id: Uuid,
76 request: UpdateTicketRequest,
77 ) -> Result<TicketResponse, AppError> {
78 let mut ticket = self
79 .ticket_repository
80 .find_by_id(id)
81 .await
82 .map_err(AppError::from)?
83 .ok_or_else(|| AppError::NotFound(format!("ticket {}", id)))?;
84
85 if !ticket.is_currently_editable() {
86 return Err(AppError::TicketImmutable);
87 }
88
89 if let Some(title) = request.title {
90 let trimmed = title.trim().to_string();
91 if trimmed.is_empty() {
92 return Err(AppError::Validation("Title cannot be empty".to_string()));
93 }
94 if trimmed.len() > 200 {
95 return Err(AppError::Validation(
96 "Title cannot exceed 200 characters".to_string(),
97 ));
98 }
99 ticket.title = trimmed;
100 }
101 if let Some(description) = request.description {
102 let trimmed = description.trim().to_string();
103 if trimmed.is_empty() {
104 return Err(AppError::Validation(
105 "Description cannot be empty".to_string(),
106 ));
107 }
108 if trimmed.len() > 5000 {
109 return Err(AppError::Validation(
110 "Description cannot exceed 5000 characters".to_string(),
111 ));
112 }
113 ticket.description = trimmed;
114 }
115 if let Some(category) = request.category {
116 ticket.category = category;
117 }
118 if let Some(priority) = request.priority {
119 ticket.priority = priority;
120 }
121 if let Some(severity) = request.severity {
122 ticket.severity = Some(severity);
123 }
124 if let Some(incident_date) = request.incident_date {
125 ticket.incident_date = Some(incident_date);
126 }
127 if let Some(evidence) = request.evidence_attachments {
128 ticket.evidence_attachments = evidence;
129 }
130 if let Some(witnesses) = request.witnesses {
131 ticket.witnesses = witnesses;
132 }
133 ticket.updated_at = chrono::Utc::now();
134
135 ticket.validate_invariants()?;
138
139 let updated = self
140 .ticket_repository
141 .update(&ticket)
142 .await
143 .map_err(AppError::from)?;
144 Ok(TicketResponse::from(updated))
145 }
146
147 pub async fn get_ticket(&self, id: Uuid) -> Result<Option<TicketResponse>, String> {
149 match self.ticket_repository.find_by_id(id).await? {
150 Some(ticket) => Ok(Some(TicketResponse::from(ticket))),
151 None => Ok(None),
152 }
153 }
154
155 pub async fn list_tickets_by_building(
157 &self,
158 building_id: Uuid,
159 ) -> Result<Vec<TicketResponse>, String> {
160 let tickets = self.ticket_repository.find_by_building(building_id).await?;
161 Ok(tickets.into_iter().map(TicketResponse::from).collect())
162 }
163
164 pub async fn list_tickets_by_organization(
166 &self,
167 organization_id: Uuid,
168 ) -> Result<Vec<TicketResponse>, String> {
169 let tickets = self
170 .ticket_repository
171 .find_by_organization(organization_id)
172 .await?;
173 Ok(tickets.into_iter().map(TicketResponse::from).collect())
174 }
175
176 pub async fn list_my_tickets(&self, created_by: Uuid) -> Result<Vec<TicketResponse>, String> {
178 let tickets = self
179 .ticket_repository
180 .find_by_created_by(created_by)
181 .await?;
182 Ok(tickets.into_iter().map(TicketResponse::from).collect())
183 }
184
185 pub async fn list_assigned_tickets(
187 &self,
188 assigned_to: Uuid,
189 ) -> Result<Vec<TicketResponse>, String> {
190 let tickets = self
191 .ticket_repository
192 .find_by_assigned_to(assigned_to)
193 .await?;
194 Ok(tickets.into_iter().map(TicketResponse::from).collect())
195 }
196
197 pub async fn list_tickets_by_status(
199 &self,
200 building_id: Uuid,
201 status: TicketStatus,
202 ) -> Result<Vec<TicketResponse>, String> {
203 let tickets = self
204 .ticket_repository
205 .find_by_status(building_id, status)
206 .await?;
207 Ok(tickets.into_iter().map(TicketResponse::from).collect())
208 }
209
210 pub async fn assign_ticket(
212 &self,
213 id: Uuid,
214 request: AssignTicketRequest,
215 ) -> Result<TicketResponse, String> {
216 let mut ticket = self
217 .ticket_repository
218 .find_by_id(id)
219 .await?
220 .ok_or_else(|| "Ticket not found".to_string())?;
221
222 ticket.assign(request.assigned_to)?;
223
224 let updated = self.ticket_repository.update(&ticket).await?;
225 Ok(TicketResponse::from(updated))
226 }
227
228 pub async fn start_work(&self, id: Uuid) -> Result<TicketResponse, String> {
230 let mut ticket = self
231 .ticket_repository
232 .find_by_id(id)
233 .await?
234 .ok_or_else(|| "Ticket not found".to_string())?;
235
236 ticket.start_work()?;
237
238 let updated = self.ticket_repository.update(&ticket).await?;
239 Ok(TicketResponse::from(updated))
240 }
241
242 pub async fn resolve_ticket(
244 &self,
245 id: Uuid,
246 request: ResolveTicketRequest,
247 ) -> Result<TicketResponse, String> {
248 let mut ticket = self
249 .ticket_repository
250 .find_by_id(id)
251 .await?
252 .ok_or_else(|| "Ticket not found".to_string())?;
253
254 ticket.resolve(request.resolution_notes)?;
255
256 let updated = self.ticket_repository.update(&ticket).await?;
257 Ok(TicketResponse::from(updated))
258 }
259
260 pub async fn close_ticket(&self, id: Uuid) -> Result<TicketResponse, String> {
262 let mut ticket = self
263 .ticket_repository
264 .find_by_id(id)
265 .await?
266 .ok_or_else(|| "Ticket not found".to_string())?;
267
268 ticket.close()?;
269
270 let updated = self.ticket_repository.update(&ticket).await?;
271 Ok(TicketResponse::from(updated))
272 }
273
274 pub async fn cancel_ticket(
276 &self,
277 id: Uuid,
278 request: CancelTicketRequest,
279 ) -> Result<TicketResponse, String> {
280 let mut ticket = self
281 .ticket_repository
282 .find_by_id(id)
283 .await?
284 .ok_or_else(|| "Ticket not found".to_string())?;
285
286 ticket.cancel(request.reason)?;
287
288 let updated = self.ticket_repository.update(&ticket).await?;
289 Ok(TicketResponse::from(updated))
290 }
291
292 pub async fn reopen_ticket(
294 &self,
295 id: Uuid,
296 request: ReopenTicketRequest,
297 ) -> Result<TicketResponse, String> {
298 let mut ticket = self
299 .ticket_repository
300 .find_by_id(id)
301 .await?
302 .ok_or_else(|| "Ticket not found".to_string())?;
303
304 ticket.reopen(request.reason)?;
305
306 let updated = self.ticket_repository.update(&ticket).await?;
307 Ok(TicketResponse::from(updated))
308 }
309
310 pub async fn delete_ticket(&self, id: Uuid) -> Result<bool, String> {
312 let ticket = self
313 .ticket_repository
314 .find_by_id(id)
315 .await?
316 .ok_or_else(|| "Ticket not found".to_string())?;
317
318 if ticket.status != TicketStatus::Open {
320 return Err("Can only delete tickets with Open status".to_string());
321 }
322
323 self.ticket_repository.delete(id).await
324 }
325
326 pub async fn get_ticket_statistics(
328 &self,
329 building_id: Uuid,
330 ) -> Result<TicketStatistics, String> {
331 let total = self
332 .ticket_repository
333 .count_by_building(building_id)
334 .await?;
335
336 let open = self
337 .ticket_repository
338 .count_by_status(building_id, TicketStatus::Open)
339 .await?;
340
341 let assigned: i64 = 0;
343
344 let in_progress = self
345 .ticket_repository
346 .count_by_status(building_id, TicketStatus::InProgress)
347 .await?;
348
349 let resolved = self
350 .ticket_repository
351 .count_by_status(building_id, TicketStatus::Resolved)
352 .await?;
353
354 let closed = self
355 .ticket_repository
356 .count_by_status(building_id, TicketStatus::Closed)
357 .await?;
358
359 let cancelled = self
360 .ticket_repository
361 .count_by_status(building_id, TicketStatus::Cancelled)
362 .await?;
363
364 let overdue_tickets = self
365 .ticket_repository
366 .find_by_building(building_id)
367 .await?
368 .iter()
369 .filter(|t| t.is_overdue(7))
370 .count() as i64;
371
372 Ok(TicketStatistics {
373 total,
374 open,
375 assigned,
376 in_progress,
377 resolved,
378 closed,
379 cancelled,
380 overdue_tickets,
381 })
382 }
383
384 pub async fn get_overdue_tickets(
386 &self,
387 building_id: Uuid,
388 max_days: i64,
389 ) -> Result<Vec<TicketResponse>, String> {
390 let tickets = self.ticket_repository.find_by_building(building_id).await?;
391
392 let overdue: Vec<TicketResponse> = tickets
393 .into_iter()
394 .filter(|ticket| ticket.is_overdue(max_days))
395 .map(TicketResponse::from)
396 .collect();
397
398 Ok(overdue)
399 }
400
401 pub async fn get_ticket_statistics_by_organization(
403 &self,
404 organization_id: Uuid,
405 ) -> Result<TicketStatistics, String> {
406 let total = self
407 .ticket_repository
408 .count_by_organization(organization_id)
409 .await?;
410
411 let open = self
412 .ticket_repository
413 .count_by_organization_and_status(organization_id, TicketStatus::Open)
414 .await?;
415
416 let assigned: i64 = 0;
417
418 let in_progress = self
419 .ticket_repository
420 .count_by_organization_and_status(organization_id, TicketStatus::InProgress)
421 .await?;
422
423 let resolved = self
424 .ticket_repository
425 .count_by_organization_and_status(organization_id, TicketStatus::Resolved)
426 .await?;
427
428 let closed = self
429 .ticket_repository
430 .count_by_organization_and_status(organization_id, TicketStatus::Closed)
431 .await?;
432
433 let cancelled = self
434 .ticket_repository
435 .count_by_organization_and_status(organization_id, TicketStatus::Cancelled)
436 .await?;
437
438 let overdue_tickets = self
439 .ticket_repository
440 .find_by_organization(organization_id)
441 .await?
442 .iter()
443 .filter(|t| t.is_overdue(7))
444 .count() as i64;
445
446 Ok(TicketStatistics {
447 total,
448 open,
449 assigned,
450 in_progress,
451 resolved,
452 closed,
453 cancelled,
454 overdue_tickets,
455 })
456 }
457
458 pub async fn get_overdue_tickets_by_organization(
460 &self,
461 organization_id: Uuid,
462 max_days: i64,
463 ) -> Result<Vec<TicketResponse>, String> {
464 let tickets = self
465 .ticket_repository
466 .find_by_organization(organization_id)
467 .await?;
468
469 let overdue: Vec<TicketResponse> = tickets
470 .into_iter()
471 .filter(|ticket| ticket.is_overdue(max_days))
472 .map(TicketResponse::from)
473 .collect();
474
475 Ok(overdue)
476 }
477
478 pub async fn send_work_order(&self, ticket_id: Uuid) -> Result<TicketResponse, String> {
481 let mut ticket = self
482 .ticket_repository
483 .find_by_id(ticket_id)
484 .await?
485 .ok_or_else(|| "Ticket not found".to_string())?;
486
487 ticket.send_work_order_to_contractor()?;
488 let updated = self.ticket_repository.update(&ticket).await?;
489
490 Ok(TicketResponse::from(updated))
491 }
492}
493
494#[derive(Debug, serde::Serialize)]
496pub struct TicketStatistics {
497 #[serde(rename = "total_tickets")]
498 pub total: i64,
499 #[serde(rename = "open_tickets")]
500 pub open: i64,
501 #[serde(rename = "assigned_tickets")]
502 pub assigned: i64,
503 #[serde(rename = "in_progress_tickets")]
504 pub in_progress: i64,
505 #[serde(rename = "resolved_tickets")]
506 pub resolved: i64,
507 #[serde(rename = "closed_tickets")]
508 pub closed: i64,
509 #[serde(rename = "cancelled_tickets")]
510 pub cancelled: i64,
511 pub overdue_tickets: i64,
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517 use crate::application::ports::TicketRepository;
518 use crate::domain::entities::{Ticket, TicketCategory, TicketPriority, TicketStatus};
519 use async_trait::async_trait;
520 use std::collections::HashMap;
521 use std::sync::Mutex;
522
523 struct MockTicketRepository {
526 tickets: Mutex<HashMap<Uuid, Ticket>>,
527 }
528
529 impl MockTicketRepository {
530 fn new() -> Self {
531 Self {
532 tickets: Mutex::new(HashMap::new()),
533 }
534 }
535 }
536
537 #[async_trait]
538 impl TicketRepository for MockTicketRepository {
539 async fn create(&self, ticket: &Ticket) -> Result<Ticket, String> {
540 self.tickets
541 .lock()
542 .unwrap()
543 .insert(ticket.id, ticket.clone());
544 Ok(ticket.clone())
545 }
546
547 async fn find_by_id(&self, id: Uuid) -> Result<Option<Ticket>, String> {
548 Ok(self.tickets.lock().unwrap().get(&id).cloned())
549 }
550
551 async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Ticket>, String> {
552 Ok(self
553 .tickets
554 .lock()
555 .unwrap()
556 .values()
557 .filter(|t| t.building_id == building_id)
558 .cloned()
559 .collect())
560 }
561
562 async fn find_by_organization(&self, organization_id: Uuid) -> Result<Vec<Ticket>, String> {
563 Ok(self
564 .tickets
565 .lock()
566 .unwrap()
567 .values()
568 .filter(|t| t.organization_id == organization_id)
569 .cloned()
570 .collect())
571 }
572
573 async fn find_by_created_by(&self, created_by: Uuid) -> Result<Vec<Ticket>, String> {
574 Ok(self
575 .tickets
576 .lock()
577 .unwrap()
578 .values()
579 .filter(|t| t.created_by == created_by)
580 .cloned()
581 .collect())
582 }
583
584 async fn find_by_assigned_to(&self, assigned_to: Uuid) -> Result<Vec<Ticket>, String> {
585 Ok(self
586 .tickets
587 .lock()
588 .unwrap()
589 .values()
590 .filter(|t| t.assigned_to == Some(assigned_to))
591 .cloned()
592 .collect())
593 }
594
595 async fn find_by_status(
596 &self,
597 building_id: Uuid,
598 status: TicketStatus,
599 ) -> Result<Vec<Ticket>, String> {
600 Ok(self
601 .tickets
602 .lock()
603 .unwrap()
604 .values()
605 .filter(|t| t.building_id == building_id && t.status == status)
606 .cloned()
607 .collect())
608 }
609
610 async fn update(&self, ticket: &Ticket) -> Result<Ticket, String> {
611 self.tickets
612 .lock()
613 .unwrap()
614 .insert(ticket.id, ticket.clone());
615 Ok(ticket.clone())
616 }
617
618 async fn delete(&self, id: Uuid) -> Result<bool, String> {
619 Ok(self.tickets.lock().unwrap().remove(&id).is_some())
620 }
621
622 async fn count_by_building(&self, building_id: Uuid) -> Result<i64, String> {
623 Ok(self
624 .tickets
625 .lock()
626 .unwrap()
627 .values()
628 .filter(|t| t.building_id == building_id)
629 .count() as i64)
630 }
631
632 async fn count_by_status(
633 &self,
634 building_id: Uuid,
635 status: TicketStatus,
636 ) -> Result<i64, String> {
637 Ok(self
638 .tickets
639 .lock()
640 .unwrap()
641 .values()
642 .filter(|t| t.building_id == building_id && t.status == status)
643 .count() as i64)
644 }
645
646 async fn count_by_organization(&self, organization_id: Uuid) -> Result<i64, String> {
647 Ok(self
648 .tickets
649 .lock()
650 .unwrap()
651 .values()
652 .filter(|t| t.organization_id == organization_id)
653 .count() as i64)
654 }
655
656 async fn count_by_organization_and_status(
657 &self,
658 organization_id: Uuid,
659 status: TicketStatus,
660 ) -> Result<i64, String> {
661 Ok(self
662 .tickets
663 .lock()
664 .unwrap()
665 .values()
666 .filter(|t| t.organization_id == organization_id && t.status == status)
667 .count() as i64)
668 }
669 }
670
671 fn make_use_cases(repo: Arc<dyn TicketRepository>) -> TicketUseCases {
674 TicketUseCases::new(repo)
675 }
676
677 fn make_create_request(building_id: Uuid) -> CreateTicketRequest {
678 CreateTicketRequest {
679 building_id,
680 unit_id: None,
681 title: "Fuite d'eau cuisine".to_string(),
682 description: "L'eau coule sous l'évier de la cuisine commune".to_string(),
683 category: TicketCategory::Plumbing,
684 priority: TicketPriority::High,
685 kind: None,
687 severity: None,
688 incident_date: None,
689 evidence_attachments: Vec::new(),
690 witnesses: Vec::new(),
691 }
692 }
693
694 async fn create_ticket_helper(
696 use_cases: &TicketUseCases,
697 org_id: Uuid,
698 building_id: Uuid,
699 ) -> TicketResponse {
700 let user_id = Uuid::new_v4();
701 let request = make_create_request(building_id);
702 use_cases
703 .create_ticket(org_id, user_id, request)
704 .await
705 .expect("create_ticket should succeed")
706 }
707
708 #[tokio::test]
711 async fn test_create_ticket_success() {
712 let repo = Arc::new(MockTicketRepository::new());
713 let use_cases = make_use_cases(repo);
714
715 let org_id = Uuid::new_v4();
716 let building_id = Uuid::new_v4();
717 let user_id = Uuid::new_v4();
718 let request = make_create_request(building_id);
719
720 let result = use_cases.create_ticket(org_id, user_id, request).await;
721
722 assert!(result.is_ok());
723 let ticket = result.unwrap();
724 assert_eq!(ticket.status, TicketStatus::Open);
725 assert_eq!(ticket.building_id, building_id);
726 assert_eq!(ticket.organization_id, org_id);
727 assert_eq!(ticket.created_by, user_id);
728 assert_eq!(ticket.title, "Fuite d'eau cuisine");
729 assert!(ticket.assigned_to.is_none());
730 assert!(ticket.resolution_notes.is_none());
731 }
732
733 #[tokio::test]
734 async fn test_create_ticket_empty_title_fails() {
735 let repo = Arc::new(MockTicketRepository::new());
736 let use_cases = make_use_cases(repo);
737
738 let request = CreateTicketRequest {
739 building_id: Uuid::new_v4(),
740 unit_id: None,
741 title: " ".to_string(),
742 description: "Some description".to_string(),
743 category: TicketCategory::Electrical,
744 priority: TicketPriority::Low,
745 kind: None,
746 severity: None,
747 incident_date: None,
748 evidence_attachments: Vec::new(),
749 witnesses: Vec::new(),
750 };
751
752 let result = use_cases
753 .create_ticket(Uuid::new_v4(), Uuid::new_v4(), request)
754 .await;
755
756 assert!(result.is_err());
757 assert!(result.unwrap_err().contains("Title cannot be empty"));
758 }
759
760 #[tokio::test]
761 async fn test_assign_contractor_transitions_to_in_progress() {
762 let repo = Arc::new(MockTicketRepository::new());
763 let use_cases = make_use_cases(repo);
764
765 let org_id = Uuid::new_v4();
766 let building_id = Uuid::new_v4();
767 let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
768 assert_eq!(ticket.status, TicketStatus::Open);
769
770 let contractor_id = Uuid::new_v4();
771 let assign_request = AssignTicketRequest {
772 assigned_to: contractor_id,
773 };
774
775 let result = use_cases.assign_ticket(ticket.id, assign_request).await;
776
777 assert!(result.is_ok());
778 let updated = result.unwrap();
779 assert_eq!(updated.status, TicketStatus::InProgress);
780 assert_eq!(updated.assigned_to, Some(contractor_id));
781 }
782
783 #[tokio::test]
784 async fn test_start_work_open_to_in_progress() {
785 let repo = Arc::new(MockTicketRepository::new());
786 let use_cases = make_use_cases(repo);
787
788 let org_id = Uuid::new_v4();
789 let building_id = Uuid::new_v4();
790 let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
791
792 let result = use_cases.start_work(ticket.id).await;
793
794 assert!(result.is_ok());
795 assert_eq!(result.unwrap().status, TicketStatus::InProgress);
796 }
797
798 #[tokio::test]
799 async fn test_resolve_ticket_with_notes() {
800 let repo = Arc::new(MockTicketRepository::new());
801 let use_cases = make_use_cases(repo);
802
803 let org_id = Uuid::new_v4();
804 let building_id = Uuid::new_v4();
805 let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
806
807 use_cases.start_work(ticket.id).await.unwrap();
809
810 let resolve_request = ResolveTicketRequest {
811 resolution_notes: "Fuite réparée, joint remplacé".to_string(),
812 };
813 let result = use_cases.resolve_ticket(ticket.id, resolve_request).await;
814
815 assert!(result.is_ok());
816 let resolved = result.unwrap();
817 assert_eq!(resolved.status, TicketStatus::Resolved);
818 assert_eq!(
819 resolved.resolution_notes.as_deref(),
820 Some("Fuite réparée, joint remplacé")
821 );
822 assert!(resolved.resolved_at.is_some());
823 }
824
825 #[tokio::test]
826 async fn test_close_ticket_after_resolution() {
827 let repo = Arc::new(MockTicketRepository::new());
828 let use_cases = make_use_cases(repo);
829
830 let org_id = Uuid::new_v4();
831 let building_id = Uuid::new_v4();
832 let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
833
834 use_cases.start_work(ticket.id).await.unwrap();
836 use_cases
837 .resolve_ticket(
838 ticket.id,
839 ResolveTicketRequest {
840 resolution_notes: "Done".to_string(),
841 },
842 )
843 .await
844 .unwrap();
845
846 let result = use_cases.close_ticket(ticket.id).await;
847
848 assert!(result.is_ok());
849 let closed = result.unwrap();
850 assert_eq!(closed.status, TicketStatus::Closed);
851 assert!(closed.closed_at.is_some());
852 }
853
854 #[tokio::test]
855 async fn test_close_open_ticket_fails() {
856 let repo = Arc::new(MockTicketRepository::new());
857 let use_cases = make_use_cases(repo);
858
859 let org_id = Uuid::new_v4();
860 let building_id = Uuid::new_v4();
861 let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
862
863 let result = use_cases.close_ticket(ticket.id).await;
865
866 assert!(result.is_err());
867 assert!(result.unwrap_err().contains("Must be Resolved first"));
868 }
869
870 #[tokio::test]
871 async fn test_cancel_ticket_with_reason() {
872 let repo = Arc::new(MockTicketRepository::new());
873 let use_cases = make_use_cases(repo);
874
875 let org_id = Uuid::new_v4();
876 let building_id = Uuid::new_v4();
877 let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
878
879 let cancel_request = CancelTicketRequest {
880 reason: "Erreur de déclaration, problème déjà résolu".to_string(),
881 };
882 let result = use_cases.cancel_ticket(ticket.id, cancel_request).await;
883
884 assert!(result.is_ok());
885 let cancelled = result.unwrap();
886 assert_eq!(cancelled.status, TicketStatus::Cancelled);
887 assert!(cancelled
888 .resolution_notes
889 .as_deref()
890 .unwrap()
891 .contains("CANCELLED"));
892 }
893
894 #[tokio::test]
895 async fn test_reopen_resolved_ticket() {
896 let repo = Arc::new(MockTicketRepository::new());
897 let use_cases = make_use_cases(repo);
898
899 let org_id = Uuid::new_v4();
900 let building_id = Uuid::new_v4();
901 let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
902
903 use_cases.start_work(ticket.id).await.unwrap();
905 use_cases
906 .resolve_ticket(
907 ticket.id,
908 ResolveTicketRequest {
909 resolution_notes: "Fixed".to_string(),
910 },
911 )
912 .await
913 .unwrap();
914
915 let reopen_request = ReopenTicketRequest {
917 reason: "Problème persiste après intervention".to_string(),
918 };
919 let result = use_cases.reopen_ticket(ticket.id, reopen_request).await;
920
921 assert!(result.is_ok());
922 let reopened = result.unwrap();
923 assert_eq!(reopened.status, TicketStatus::InProgress);
924 assert!(reopened.resolved_at.is_none());
925 assert!(reopened.closed_at.is_none());
926 assert!(reopened
927 .resolution_notes
928 .as_deref()
929 .unwrap()
930 .contains("REOPENED"));
931 }
932
933 #[tokio::test]
934 async fn test_reopen_open_ticket_fails() {
935 let repo = Arc::new(MockTicketRepository::new());
936 let use_cases = make_use_cases(repo);
937
938 let org_id = Uuid::new_v4();
939 let building_id = Uuid::new_v4();
940 let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
941
942 let reopen_request = ReopenTicketRequest {
944 reason: "Some reason".to_string(),
945 };
946 let result = use_cases.reopen_ticket(ticket.id, reopen_request).await;
947
948 assert!(result.is_err());
949 assert!(result
950 .unwrap_err()
951 .contains("Can only reopen resolved or closed tickets"));
952 }
953
954 #[tokio::test]
955 async fn test_send_work_order_in_progress_ticket() {
956 let repo = Arc::new(MockTicketRepository::new());
957 let use_cases = make_use_cases(repo);
958
959 let org_id = Uuid::new_v4();
960 let building_id = Uuid::new_v4();
961 let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
962
963 let contractor_id = Uuid::new_v4();
965 use_cases
966 .assign_ticket(
967 ticket.id,
968 AssignTicketRequest {
969 assigned_to: contractor_id,
970 },
971 )
972 .await
973 .unwrap();
974
975 let result = use_cases.send_work_order(ticket.id).await;
977
978 assert!(result.is_ok());
979 let updated = result.unwrap();
980 assert!(updated.work_order_sent_at.is_some());
981 assert_eq!(updated.status, TicketStatus::InProgress);
982 }
983
984 #[tokio::test]
985 async fn test_send_work_order_open_ticket_fails() {
986 let repo = Arc::new(MockTicketRepository::new());
987 let use_cases = make_use_cases(repo);
988
989 let org_id = Uuid::new_v4();
990 let building_id = Uuid::new_v4();
991 let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
992
993 let result = use_cases.send_work_order(ticket.id).await;
995
996 assert!(result.is_err());
997 assert!(result.unwrap_err().contains("InProgress"));
998 }
999
1000 #[tokio::test]
1001 async fn test_get_overdue_tickets() {
1002 let repo = Arc::new(MockTicketRepository::new());
1003 let use_cases = make_use_cases(repo.clone());
1004
1005 let org_id = Uuid::new_v4();
1006 let building_id = Uuid::new_v4();
1007 let user_id = Uuid::new_v4();
1008
1009 let request = make_create_request(building_id);
1011 let created = use_cases
1012 .create_ticket(org_id, user_id, request)
1013 .await
1014 .unwrap();
1015
1016 {
1018 let mut store = repo.tickets.lock().unwrap();
1019 if let Some(ticket) = store.get_mut(&created.id) {
1020 ticket.created_at = chrono::Utc::now() - chrono::Duration::days(10);
1021 }
1022 }
1023
1024 let overdue = use_cases.get_overdue_tickets(building_id, 5).await.unwrap();
1025 assert_eq!(overdue.len(), 1);
1026 assert_eq!(overdue[0].id, created.id);
1027
1028 let not_overdue = use_cases
1030 .get_overdue_tickets(building_id, 15)
1031 .await
1032 .unwrap();
1033 assert_eq!(not_overdue.len(), 0);
1034 }
1035
1036 #[tokio::test]
1037 async fn test_get_ticket_statistics() {
1038 let repo = Arc::new(MockTicketRepository::new());
1039 let use_cases = make_use_cases(repo);
1040
1041 let org_id = Uuid::new_v4();
1042 let building_id = Uuid::new_v4();
1043
1044 let _t1 = create_ticket_helper(&use_cases, org_id, building_id).await; let t2 = create_ticket_helper(&use_cases, org_id, building_id).await;
1047 let t3 = create_ticket_helper(&use_cases, org_id, building_id).await;
1048
1049 use_cases.start_work(t2.id).await.unwrap();
1051 use_cases
1052 .resolve_ticket(
1053 t2.id,
1054 ResolveTicketRequest {
1055 resolution_notes: "Done".to_string(),
1056 },
1057 )
1058 .await
1059 .unwrap();
1060
1061 use_cases
1063 .cancel_ticket(
1064 t3.id,
1065 CancelTicketRequest {
1066 reason: "Duplicate".to_string(),
1067 },
1068 )
1069 .await
1070 .unwrap();
1071
1072 let stats = use_cases.get_ticket_statistics(building_id).await.unwrap();
1073
1074 assert_eq!(stats.total, 3);
1075 assert_eq!(stats.open, 1); assert_eq!(stats.in_progress, 0);
1077 assert_eq!(stats.resolved, 1); assert_eq!(stats.closed, 0);
1079 assert_eq!(stats.cancelled, 1); }
1081
1082 #[tokio::test]
1083 async fn test_delete_open_ticket_succeeds() {
1084 let repo = Arc::new(MockTicketRepository::new());
1085 let use_cases = make_use_cases(repo);
1086
1087 let org_id = Uuid::new_v4();
1088 let building_id = Uuid::new_v4();
1089 let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
1090
1091 let result = use_cases.delete_ticket(ticket.id).await;
1092
1093 assert!(result.is_ok());
1094 assert!(result.unwrap());
1095
1096 let fetched = use_cases.get_ticket(ticket.id).await.unwrap();
1098 assert!(fetched.is_none());
1099 }
1100
1101 #[tokio::test]
1102 async fn test_delete_in_progress_ticket_fails() {
1103 let repo = Arc::new(MockTicketRepository::new());
1104 let use_cases = make_use_cases(repo);
1105
1106 let org_id = Uuid::new_v4();
1107 let building_id = Uuid::new_v4();
1108 let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
1109
1110 use_cases.start_work(ticket.id).await.unwrap();
1112
1113 let result = use_cases.delete_ticket(ticket.id).await;
1114
1115 assert!(result.is_err());
1116 assert!(result
1117 .unwrap_err()
1118 .contains("Can only delete tickets with Open status"));
1119 }
1120
1121 use crate::application::dto::UpdateTicketRequest;
1127 use crate::application::error::AppError;
1128 use crate::domain::entities::{TicketKind, TicketSeverity};
1129
1130 fn make_complaint_request(building_id: Uuid) -> CreateTicketRequest {
1131 CreateTicketRequest {
1132 building_id,
1133 unit_id: None,
1134 title: "Plainte tapage nocturne".to_string(),
1135 description: "Bruit récurrent depuis 3 semaines, 2 témoins".to_string(),
1136 category: TicketCategory::Other,
1137 priority: TicketPriority::High,
1138 kind: Some(TicketKind::Complaint),
1139 severity: Some(TicketSeverity::Critical),
1140 incident_date: Some(chrono::Utc::now() - chrono::Duration::hours(2)),
1141 evidence_attachments: vec![
1142 "https://store.test/a.jpg".to_string(),
1143 "https://store.test/b.jpg".to_string(),
1144 "https://store.test/c.jpg".to_string(),
1145 ],
1146 witnesses: vec![Uuid::new_v4(), Uuid::new_v4()],
1147 }
1148 }
1149
1150 #[tokio::test]
1153 async fn happy_create_complaint_with_severity_evidence_witnesses() {
1154 let repo = Arc::new(MockTicketRepository::new());
1155 let use_cases = make_use_cases(repo);
1156
1157 let org_id = Uuid::new_v4();
1158 let building_id = Uuid::new_v4();
1159 let creator = Uuid::new_v4();
1160
1161 let res = use_cases
1162 .create_ticket(org_id, creator, make_complaint_request(building_id))
1163 .await
1164 .expect("complaint creation should succeed");
1165
1166 assert_eq!(res.kind, TicketKind::Complaint);
1167 assert_eq!(res.severity, Some(TicketSeverity::Critical));
1168 assert_eq!(res.evidence_attachments.len(), 3);
1169 assert_eq!(res.witnesses.len(), 2);
1170 }
1171
1172 #[tokio::test]
1173 async fn happy_update_ticket_within_window_persists_changes() {
1174 let repo = Arc::new(MockTicketRepository::new());
1175 let use_cases = make_use_cases(repo);
1176
1177 let org_id = Uuid::new_v4();
1178 let building_id = Uuid::new_v4();
1179 let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
1180
1181 let update = UpdateTicketRequest {
1182 title: Some("Titre corrigé".to_string()),
1183 description: Some("Description corrigée".to_string()),
1184 ..Default::default()
1185 };
1186 let updated = use_cases
1187 .update_ticket_fields(ticket.id, update)
1188 .await
1189 .expect("edit within 5min window should succeed");
1190 assert_eq!(updated.title, "Titre corrigé");
1191 assert_eq!(updated.description, "Description corrigée");
1192 }
1193
1194 #[tokio::test]
1197 async fn edge_pre_3_6_client_without_kind_defaults_to_request() {
1198 let repo = Arc::new(MockTicketRepository::new());
1199 let use_cases = make_use_cases(repo);
1200
1201 let org_id = Uuid::new_v4();
1202 let building_id = Uuid::new_v4();
1203 let user_id = Uuid::new_v4();
1204 let request = make_create_request(building_id);
1205
1206 let res = use_cases
1207 .create_ticket(org_id, user_id, request)
1208 .await
1209 .expect("retro-compat path should succeed");
1210 assert_eq!(res.kind, TicketKind::Request);
1211 assert!(res.severity.is_none());
1212 assert!(res.evidence_attachments.is_empty());
1213 assert!(res.witnesses.is_empty());
1214 }
1215
1216 #[tokio::test]
1219 async fn security_update_past_5_minute_window_returns_ticket_immutable_403() {
1220 let repo = Arc::new(MockTicketRepository::new());
1221 let use_cases = make_use_cases(repo.clone());
1222
1223 let org_id = Uuid::new_v4();
1224 let building_id = Uuid::new_v4();
1225 let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
1226
1227 {
1229 let mut store = repo.tickets.lock().unwrap();
1230 if let Some(t) = store.get_mut(&ticket.id) {
1231 t.created_at = chrono::Utc::now() - chrono::Duration::minutes(6);
1232 }
1233 }
1234
1235 let update = UpdateTicketRequest {
1236 title: Some("Tentative tardive".to_string()),
1237 ..Default::default()
1238 };
1239 let err = use_cases
1240 .update_ticket_fields(ticket.id, update)
1241 .await
1242 .unwrap_err();
1243 assert!(matches!(err, AppError::TicketImmutable));
1244 }
1245
1246 #[tokio::test]
1249 async fn negative_create_complaint_without_severity_returns_validation_422() {
1250 let repo = Arc::new(MockTicketRepository::new());
1251 let use_cases = make_use_cases(repo);
1252
1253 let org_id = Uuid::new_v4();
1254 let building_id = Uuid::new_v4();
1255
1256 let mut req = make_complaint_request(building_id);
1257 req.severity = None;
1258 let err = use_cases
1259 .create_ticket(org_id, Uuid::new_v4(), req)
1260 .await
1261 .unwrap_err();
1262 assert!(
1263 err.contains("severity"),
1264 "expected severity-related error, got: {err}"
1265 );
1266 }
1267
1268 #[tokio::test]
1269 async fn negative_update_unknown_ticket_returns_not_found_404() {
1270 let repo = Arc::new(MockTicketRepository::new());
1271 let use_cases = make_use_cases(repo);
1272
1273 let err = use_cases
1274 .update_ticket_fields(Uuid::new_v4(), UpdateTicketRequest::default())
1275 .await
1276 .unwrap_err();
1277 assert!(matches!(err, AppError::NotFound(_)));
1278 }
1279}