1use crate::application::error::AppError;
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::collections::HashSet;
5use uuid::Uuid;
6
7pub const TICKET_EDIT_WINDOW_MINUTES: i64 = 5;
11
12pub const MAX_EVIDENCE_ATTACHMENTS: usize = 10;
15
16pub const MAX_WITNESSES: usize = 10;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, utoipa::ToSchema)]
22#[serde(rename_all = "lowercase")]
23pub enum TicketKind {
24 #[default]
26 Request,
27 Complaint,
29}
30
31impl std::fmt::Display for TicketKind {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 match self {
34 TicketKind::Request => write!(f, "request"),
35 TicketKind::Complaint => write!(f, "complaint"),
36 }
37 }
38}
39
40impl std::str::FromStr for TicketKind {
41 type Err = AppError;
42
43 fn from_str(s: &str) -> Result<Self, Self::Err> {
44 match s.trim().to_lowercase().as_str() {
45 "request" => Ok(TicketKind::Request),
46 "complaint" => Ok(TicketKind::Complaint),
47 other => Err(AppError::Validation(format!(
48 "Invalid ticket kind: {}",
49 other
50 ))),
51 }
52 }
53}
54
55#[derive(
58 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, utoipa::ToSchema,
59)]
60#[serde(rename_all = "lowercase")]
61pub enum TicketSeverity {
62 Low,
63 Normal,
64 High,
65 Critical,
66}
67
68impl std::fmt::Display for TicketSeverity {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 match self {
71 TicketSeverity::Low => write!(f, "low"),
72 TicketSeverity::Normal => write!(f, "normal"),
73 TicketSeverity::High => write!(f, "high"),
74 TicketSeverity::Critical => write!(f, "critical"),
75 }
76 }
77}
78
79impl std::str::FromStr for TicketSeverity {
80 type Err = AppError;
81
82 fn from_str(s: &str) -> Result<Self, Self::Err> {
83 match s.trim().to_lowercase().as_str() {
84 "low" => Ok(TicketSeverity::Low),
85 "normal" => Ok(TicketSeverity::Normal),
86 "high" => Ok(TicketSeverity::High),
87 "critical" => Ok(TicketSeverity::Critical),
88 other => Err(AppError::Validation(format!(
89 "Invalid ticket severity: {}",
90 other
91 ))),
92 }
93 }
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
98pub enum TicketCategory {
99 Plumbing, Electrical, Heating, CommonAreas, Elevator, Security, Cleaning, Landscaping, Other, }
109
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd, utoipa::ToSchema)]
112pub enum TicketPriority {
113 Low, Medium, High, Critical, }
118
119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
121pub enum TicketStatus {
122 Open, InProgress, Resolved, Closed, Cancelled, }
128
129#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
134pub struct Ticket {
135 pub id: Uuid,
136 pub organization_id: Uuid,
137 pub building_id: Uuid,
138 pub unit_id: Option<Uuid>, pub created_by: Uuid, pub assigned_to: Option<Uuid>, pub title: String,
142 pub description: String,
143 pub category: TicketCategory,
144 pub priority: TicketPriority,
145 pub status: TicketStatus,
146 pub resolution_notes: Option<String>, pub work_order_sent_at: Option<DateTime<Utc>>, #[serde(default)]
151 pub kind: TicketKind,
152 #[serde(default)]
155 pub severity: Option<TicketSeverity>,
156 #[serde(default)]
160 pub incident_date: Option<DateTime<Utc>>,
161 #[serde(default)]
164 pub evidence_attachments: Vec<String>,
165 #[serde(default)]
168 pub witnesses: Vec<Uuid>,
169 #[serde(default)]
174 pub sla_due_at: Option<DateTime<Utc>>,
175 #[serde(default)]
180 pub sla_escalated_at: Option<DateTime<Utc>>,
181 pub created_at: DateTime<Utc>,
182 pub updated_at: DateTime<Utc>,
183 pub resolved_at: Option<DateTime<Utc>>,
184 pub closed_at: Option<DateTime<Utc>>,
185}
186
187impl Ticket {
188 pub fn new(
190 organization_id: Uuid,
191 building_id: Uuid,
192 unit_id: Option<Uuid>,
193 created_by: Uuid,
194 title: String,
195 description: String,
196 category: TicketCategory,
197 priority: TicketPriority,
198 ) -> Result<Self, String> {
199 if title.trim().is_empty() {
201 return Err("Title cannot be empty".to_string());
202 }
203
204 if title.len() > 200 {
205 return Err("Title cannot exceed 200 characters".to_string());
206 }
207
208 if description.trim().is_empty() {
209 return Err("Description cannot be empty".to_string());
210 }
211
212 if description.len() > 5000 {
213 return Err("Description cannot exceed 5000 characters".to_string());
214 }
215
216 let now = Utc::now();
217
218 Ok(Self {
219 id: Uuid::new_v4(),
220 organization_id,
221 building_id,
222 unit_id,
223 created_by,
224 assigned_to: None,
225 title,
226 description,
227 category,
228 priority,
229 status: TicketStatus::Open,
230 resolution_notes: None,
231 work_order_sent_at: None,
232 kind: TicketKind::Request,
233 severity: None,
234 incident_date: None,
235 evidence_attachments: Vec::new(),
236 witnesses: Vec::new(),
237 sla_due_at: None,
239 sla_escalated_at: None,
240 created_at: now,
241 updated_at: now,
242 resolved_at: None,
243 closed_at: None,
244 })
245 }
246
247 #[allow(clippy::too_many_arguments)]
259 pub fn new_with_kind(
260 organization_id: Uuid,
261 building_id: Uuid,
262 unit_id: Option<Uuid>,
263 created_by: Uuid,
264 title: String,
265 description: String,
266 category: TicketCategory,
267 priority: TicketPriority,
268 kind: TicketKind,
269 severity: Option<TicketSeverity>,
270 incident_date: Option<DateTime<Utc>>,
271 evidence_attachments: Vec<String>,
272 witnesses: Vec<Uuid>,
273 ) -> Result<Self, AppError> {
274 let mut t = Ticket::new(
278 organization_id,
279 building_id,
280 unit_id,
281 created_by,
282 title,
283 description,
284 category,
285 priority,
286 )
287 .map_err(AppError::Validation)?;
288
289 t.kind = kind;
290 t.severity = severity;
291 t.incident_date = incident_date;
292 t.evidence_attachments = evidence_attachments;
293 t.witnesses = witnesses;
294
295 t.sla_due_at = severity.map(|s| {
299 t.created_at + crate::domain::entities::syndic_response::sla_window_for_severity(s)
300 });
301
302 t.validate_invariants()?;
303 Ok(t)
304 }
305
306 pub fn is_editable_at(&self, t: DateTime<Utc>) -> bool {
313 let window = chrono::Duration::minutes(TICKET_EDIT_WINDOW_MINUTES);
314 t - self.created_at < window
315 }
316
317 pub fn is_currently_editable(&self) -> bool {
319 self.is_editable_at(Utc::now())
320 }
321
322 pub fn validate_invariants(&self) -> Result<(), AppError> {
330 if self.kind == TicketKind::Complaint && self.severity.is_none() {
331 return Err(AppError::Validation(
332 "Complaint tickets require an explicit severity".to_string(),
333 ));
334 }
335 if self.evidence_attachments.len() > MAX_EVIDENCE_ATTACHMENTS {
336 return Err(AppError::Validation(format!(
337 "Too many evidence attachments (max {})",
338 MAX_EVIDENCE_ATTACHMENTS
339 )));
340 }
341 if self.witnesses.len() > MAX_WITNESSES {
342 return Err(AppError::Validation(format!(
343 "Too many witnesses (max {})",
344 MAX_WITNESSES
345 )));
346 }
347 if self.witnesses.contains(&self.created_by) {
348 return Err(AppError::Validation(
349 "Ticket creator cannot also be listed as a witness".to_string(),
350 ));
351 }
352 let mut seen: HashSet<Uuid> = HashSet::with_capacity(self.witnesses.len());
353 for w in &self.witnesses {
354 if !seen.insert(*w) {
355 return Err(AppError::Validation(
356 "Duplicate witness in ticket".to_string(),
357 ));
358 }
359 }
360 if self
362 .evidence_attachments
363 .iter()
364 .any(|a| a.trim().is_empty())
365 {
366 return Err(AppError::Validation(
367 "Evidence attachment URL cannot be empty".to_string(),
368 ));
369 }
370 Ok(())
371 }
372
373 pub fn assign(&mut self, user_id: Uuid) -> Result<(), String> {
375 if self.status == TicketStatus::Closed || self.status == TicketStatus::Cancelled {
376 return Err("Cannot assign a closed or cancelled ticket".to_string());
377 }
378
379 self.assigned_to = Some(user_id);
380 self.updated_at = Utc::now();
381
382 if self.status == TicketStatus::Open {
384 self.status = TicketStatus::InProgress;
385 }
386
387 Ok(())
388 }
389
390 pub fn start_work(&mut self) -> Result<(), String> {
392 match self.status {
393 TicketStatus::Open => {
394 self.status = TicketStatus::InProgress;
395 self.updated_at = Utc::now();
396 Ok(())
397 }
398 TicketStatus::InProgress => Ok(()), _ => Err(format!(
400 "Cannot start work on ticket in status {:?}",
401 self.status
402 )),
403 }
404 }
405
406 pub fn resolve(&mut self, resolution_notes: String) -> Result<(), String> {
408 if resolution_notes.trim().is_empty() {
409 return Err("Resolution notes are required".to_string());
410 }
411
412 if resolution_notes.len() > 2000 {
413 return Err("Resolution notes cannot exceed 2000 characters".to_string());
414 }
415
416 match self.status {
417 TicketStatus::Open | TicketStatus::InProgress => {
418 self.status = TicketStatus::Resolved;
419 self.resolution_notes = Some(resolution_notes);
420 self.resolved_at = Some(Utc::now());
421 self.updated_at = Utc::now();
422 Ok(())
423 }
424 TicketStatus::Resolved => {
425 self.resolution_notes = Some(resolution_notes);
427 self.updated_at = Utc::now();
428 Ok(())
429 }
430 _ => Err(format!("Cannot resolve ticket in status {:?}", self.status)),
431 }
432 }
433
434 pub fn close(&mut self) -> Result<(), String> {
436 match self.status {
437 TicketStatus::Resolved => {
438 self.status = TicketStatus::Closed;
439 self.closed_at = Some(Utc::now());
440 self.updated_at = Utc::now();
441 Ok(())
442 }
443 TicketStatus::Closed => Ok(()), _ => Err(format!(
445 "Cannot close ticket in status {:?}. Must be Resolved first.",
446 self.status
447 )),
448 }
449 }
450
451 pub fn cancel(&mut self, reason: String) -> Result<(), String> {
453 if self.status == TicketStatus::Closed {
454 return Err("Cannot cancel an already closed ticket".to_string());
455 }
456
457 if reason.trim().is_empty() {
458 return Err("Cancellation reason is required".to_string());
459 }
460
461 self.status = TicketStatus::Cancelled;
462 self.resolution_notes = Some(format!("CANCELLED: {}", reason));
463 self.updated_at = Utc::now();
464
465 Ok(())
466 }
467
468 pub fn reopen(&mut self, reason: String) -> Result<(), String> {
470 if self.status != TicketStatus::Resolved && self.status != TicketStatus::Closed {
471 return Err("Can only reopen resolved or closed tickets".to_string());
472 }
473
474 if reason.trim().is_empty() {
475 return Err("Reopen reason is required".to_string());
476 }
477
478 self.status = TicketStatus::InProgress;
479 self.resolution_notes = Some(format!(
480 "{}\n\nREOPENED: {}",
481 self.resolution_notes.as_deref().unwrap_or(""),
482 reason
483 ));
484 self.resolved_at = None;
485 self.closed_at = None;
486 self.updated_at = Utc::now();
487
488 Ok(())
489 }
490
491 pub fn send_work_order_to_contractor(&mut self) -> Result<(), String> {
494 if self.status != TicketStatus::InProgress {
495 return Err(
496 "Can only send work order to contractor for tickets in InProgress status"
497 .to_string(),
498 );
499 }
500
501 if self.assigned_to.is_none() {
502 return Err(
503 "Ticket must be assigned to a contractor before sending work order".to_string(),
504 );
505 }
506
507 self.work_order_sent_at = Some(Utc::now());
508 self.updated_at = Utc::now();
509 Ok(())
510 }
511
512 pub fn is_overdue(&self, max_days: i64) -> bool {
514 if self.status == TicketStatus::Closed || self.status == TicketStatus::Cancelled {
515 return false;
516 }
517
518 let now = Utc::now();
519 let age = now - self.created_at;
520
521 age.num_days() > max_days
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528
529 #[test]
530 fn test_create_ticket_success() {
531 let ticket = Ticket::new(
532 Uuid::new_v4(),
533 Uuid::new_v4(),
534 Some(Uuid::new_v4()),
535 Uuid::new_v4(),
536 "Fuite d'eau salle de bain".to_string(),
537 "L'eau coule du plafond de la salle de bain".to_string(),
538 TicketCategory::Plumbing,
539 TicketPriority::High,
540 );
541
542 assert!(ticket.is_ok());
543 let ticket = ticket.unwrap();
544 assert_eq!(ticket.status, TicketStatus::Open);
545 assert!(ticket.assigned_to.is_none());
546 }
547
548 #[test]
549 fn test_create_ticket_empty_title() {
550 let result = Ticket::new(
551 Uuid::new_v4(),
552 Uuid::new_v4(),
553 None,
554 Uuid::new_v4(),
555 " ".to_string(),
556 "Description".to_string(),
557 TicketCategory::Plumbing,
558 TicketPriority::Low,
559 );
560
561 assert!(result.is_err());
562 assert_eq!(result.unwrap_err(), "Title cannot be empty");
563 }
564
565 #[test]
566 fn test_assign_ticket() {
567 let mut ticket = Ticket::new(
568 Uuid::new_v4(),
569 Uuid::new_v4(),
570 None,
571 Uuid::new_v4(),
572 "Test".to_string(),
573 "Test description".to_string(),
574 TicketCategory::Electrical,
575 TicketPriority::Medium,
576 )
577 .unwrap();
578
579 let contractor_id = Uuid::new_v4();
580 let result = ticket.assign(contractor_id);
581
582 assert!(result.is_ok());
583 assert_eq!(ticket.assigned_to, Some(contractor_id));
584 assert_eq!(ticket.status, TicketStatus::InProgress); }
586
587 #[test]
588 fn test_resolve_ticket() {
589 let mut ticket = Ticket::new(
590 Uuid::new_v4(),
591 Uuid::new_v4(),
592 None,
593 Uuid::new_v4(),
594 "Test".to_string(),
595 "Test description".to_string(),
596 TicketCategory::Heating,
597 TicketPriority::Low,
598 )
599 .unwrap();
600
601 ticket.start_work().unwrap();
602
603 let result = ticket.resolve("Chaudière réparée, pièce remplacée".to_string());
604
605 assert!(result.is_ok());
606 assert_eq!(ticket.status, TicketStatus::Resolved);
607 assert!(ticket.resolved_at.is_some());
608 assert!(ticket.resolution_notes.is_some());
609 }
610
611 #[test]
612 fn test_close_ticket() {
613 let mut ticket = Ticket::new(
614 Uuid::new_v4(),
615 Uuid::new_v4(),
616 None,
617 Uuid::new_v4(),
618 "Test".to_string(),
619 "Test description".to_string(),
620 TicketCategory::CommonAreas,
621 TicketPriority::Medium,
622 )
623 .unwrap();
624
625 ticket.start_work().unwrap();
626 ticket.resolve("Fixed".to_string()).unwrap();
627
628 let result = ticket.close();
629
630 assert!(result.is_ok());
631 assert_eq!(ticket.status, TicketStatus::Closed);
632 assert!(ticket.closed_at.is_some());
633 }
634
635 #[test]
636 fn test_cannot_close_open_ticket() {
637 let mut ticket = Ticket::new(
638 Uuid::new_v4(),
639 Uuid::new_v4(),
640 None,
641 Uuid::new_v4(),
642 "Test".to_string(),
643 "Test description".to_string(),
644 TicketCategory::Elevator,
645 TicketPriority::Critical,
646 )
647 .unwrap();
648
649 let result = ticket.close();
650
651 assert!(result.is_err());
652 assert!(result.unwrap_err().contains("Must be Resolved first"));
653 }
654
655 #[test]
656 fn test_cancel_ticket() {
657 let mut ticket = Ticket::new(
658 Uuid::new_v4(),
659 Uuid::new_v4(),
660 None,
661 Uuid::new_v4(),
662 "Test".to_string(),
663 "Test description".to_string(),
664 TicketCategory::Other,
665 TicketPriority::Low,
666 )
667 .unwrap();
668
669 let result = ticket.cancel("Erreur de déclaration".to_string());
670
671 assert!(result.is_ok());
672 assert_eq!(ticket.status, TicketStatus::Cancelled);
673 }
674
675 #[test]
676 fn test_reopen_ticket() {
677 let mut ticket = Ticket::new(
678 Uuid::new_v4(),
679 Uuid::new_v4(),
680 None,
681 Uuid::new_v4(),
682 "Test".to_string(),
683 "Test description".to_string(),
684 TicketCategory::Plumbing,
685 TicketPriority::High,
686 )
687 .unwrap();
688
689 ticket.start_work().unwrap();
690 ticket.resolve("Fixed".to_string()).unwrap();
691 ticket.close().unwrap();
692
693 let result = ticket.reopen("Problème persiste".to_string());
694
695 assert!(result.is_ok());
696 assert_eq!(ticket.status, TicketStatus::InProgress);
697 assert!(ticket.closed_at.is_none());
698 assert!(ticket.resolution_notes.unwrap().contains("REOPENED"));
699 }
700
701 #[test]
702 fn test_is_overdue() {
703 let mut ticket = Ticket::new(
704 Uuid::new_v4(),
705 Uuid::new_v4(),
706 None,
707 Uuid::new_v4(),
708 "Test".to_string(),
709 "Test description".to_string(),
710 TicketCategory::Plumbing,
711 TicketPriority::High,
712 )
713 .unwrap();
714
715 ticket.created_at = Utc::now() - chrono::Duration::days(10);
717
718 assert!(ticket.is_overdue(5));
719 assert!(!ticket.is_overdue(15));
720
721 ticket.status = TicketStatus::Closed;
723 assert!(!ticket.is_overdue(5));
724 }
725
726 #[test]
727 fn test_send_work_order_success() {
728 let mut ticket = Ticket::new(
729 Uuid::new_v4(),
730 Uuid::new_v4(),
731 None,
732 Uuid::new_v4(),
733 "Test".to_string(),
734 "Test description".to_string(),
735 TicketCategory::Plumbing,
736 TicketPriority::High,
737 )
738 .unwrap();
739
740 ticket.assign(Uuid::new_v4()).unwrap();
742 assert_eq!(ticket.status, TicketStatus::InProgress);
743
744 let result = ticket.send_work_order_to_contractor();
746 assert!(result.is_ok());
747 assert!(ticket.work_order_sent_at.is_some());
748 }
749
750 #[test]
751 fn test_send_work_order_requires_assignment() {
752 let mut ticket = Ticket::new(
753 Uuid::new_v4(),
754 Uuid::new_v4(),
755 None,
756 Uuid::new_v4(),
757 "Test".to_string(),
758 "Test description".to_string(),
759 TicketCategory::Electrical,
760 TicketPriority::Medium,
761 )
762 .unwrap();
763
764 let result = ticket.send_work_order_to_contractor();
766 assert!(result.is_err());
767 assert!(result.unwrap_err().contains("InProgress"));
768 }
769
770 #[test]
771 fn test_send_work_order_requires_in_progress() {
772 let mut ticket = Ticket::new(
773 Uuid::new_v4(),
774 Uuid::new_v4(),
775 None,
776 Uuid::new_v4(),
777 "Test".to_string(),
778 "Test description".to_string(),
779 TicketCategory::Heating,
780 TicketPriority::Low,
781 )
782 .unwrap();
783
784 let contractor_id = Uuid::new_v4();
785 ticket.assign(contractor_id).unwrap();
786 ticket.start_work().unwrap();
787 ticket.resolve("Fixed".to_string()).unwrap();
788
789 let result = ticket.send_work_order_to_contractor();
791 assert!(result.is_err());
792 assert!(result.unwrap_err().contains("InProgress status"));
793 }
794
795 fn make_request_ticket() -> Ticket {
801 Ticket::new(
802 Uuid::new_v4(),
803 Uuid::new_v4(),
804 None,
805 Uuid::new_v4(),
806 "Title".to_string(),
807 "Description".to_string(),
808 TicketCategory::Plumbing,
809 TicketPriority::Medium,
810 )
811 .unwrap()
812 }
813
814 #[test]
817 fn happy_legacy_new_defaults_kind_to_request_and_empty_complaint_fields() {
818 let t = make_request_ticket();
819 assert_eq!(t.kind, TicketKind::Request);
820 assert!(t.severity.is_none());
821 assert!(t.incident_date.is_none());
822 assert!(t.evidence_attachments.is_empty());
823 assert!(t.witnesses.is_empty());
824 assert!(t.validate_invariants().is_ok());
825 assert!(t.sla_due_at.is_none());
827 assert!(t.sla_escalated_at.is_none());
828 }
829
830 #[test]
833 fn happy_new_with_kind_critical_sets_sla_due_at_24h_after_created_at() {
834 let t = Ticket::new_with_kind(
835 Uuid::new_v4(),
836 Uuid::new_v4(),
837 None,
838 Uuid::new_v4(),
839 "T".to_string(),
840 "D".to_string(),
841 TicketCategory::Other,
842 TicketPriority::High,
843 TicketKind::Complaint,
844 Some(TicketSeverity::Critical),
845 None,
846 Vec::new(),
847 Vec::new(),
848 )
849 .expect("complaint critical must succeed");
850
851 let sla = t.sla_due_at.expect("Critical severity must produce a SLA");
852 assert_eq!(sla - t.created_at, chrono::Duration::hours(24));
854 assert!(t.sla_escalated_at.is_none());
855 }
856
857 #[test]
858 fn happy_new_with_kind_no_severity_leaves_sla_due_at_none() {
859 let t = Ticket::new_with_kind(
861 Uuid::new_v4(),
862 Uuid::new_v4(),
863 None,
864 Uuid::new_v4(),
865 "T".to_string(),
866 "D".to_string(),
867 TicketCategory::Other,
868 TicketPriority::Low,
869 TicketKind::Request,
870 None,
871 None,
872 Vec::new(),
873 Vec::new(),
874 )
875 .expect("Request without severity must succeed");
876 assert!(t.sla_due_at.is_none());
877 }
878
879 #[test]
880 fn happy_new_with_kind_complaint_critical_with_evidence_and_witnesses() {
881 let org = Uuid::new_v4();
882 let bld = Uuid::new_v4();
883 let creator = Uuid::new_v4();
884 let evidence = vec![
885 "https://obj.store/a.jpg".to_string(),
886 "https://obj.store/b.jpg".to_string(),
887 "https://obj.store/c.jpg".to_string(),
888 ];
889 let witnesses = vec![Uuid::new_v4(), Uuid::new_v4()];
890
891 let t = Ticket::new_with_kind(
892 org,
893 bld,
894 None,
895 creator,
896 "Bruit nocturne récurrent".to_string(),
897 "Tapage du 3e étage, plaintes répétées".to_string(),
898 TicketCategory::Other,
899 TicketPriority::High,
900 TicketKind::Complaint,
901 Some(TicketSeverity::Critical),
902 Some(Utc::now() - chrono::Duration::hours(2)),
903 evidence.clone(),
904 witnesses.clone(),
905 )
906 .expect("Complaint with severity + ≤10 evidence + ≤10 witnesses must succeed");
907
908 assert_eq!(t.kind, TicketKind::Complaint);
909 assert_eq!(t.severity, Some(TicketSeverity::Critical));
910 assert_eq!(t.evidence_attachments, evidence);
911 assert_eq!(t.witnesses, witnesses);
912 assert!(t.incident_date.is_some());
913 }
914
915 #[test]
916 fn happy_kind_severity_display_and_from_str_roundtrip() {
917 use std::str::FromStr;
918 for k in [TicketKind::Request, TicketKind::Complaint] {
919 assert_eq!(TicketKind::from_str(&k.to_string()).unwrap(), k);
920 }
921 for s in [
922 TicketSeverity::Low,
923 TicketSeverity::Normal,
924 TicketSeverity::High,
925 TicketSeverity::Critical,
926 ] {
927 assert_eq!(TicketSeverity::from_str(&s.to_string()).unwrap(), s);
928 }
929 }
930
931 #[test]
932 fn happy_severity_ordering_supports_high_alerting_threshold() {
933 assert!(TicketSeverity::Critical > TicketSeverity::High);
935 assert!(TicketSeverity::High > TicketSeverity::Normal);
936 assert!(TicketSeverity::Normal > TicketSeverity::Low);
937 }
938
939 #[test]
942 fn edge_complaint_with_no_evidence_is_allowed_text_only_path() {
943 let creator = Uuid::new_v4();
945 let t = Ticket::new_with_kind(
946 Uuid::new_v4(),
947 Uuid::new_v4(),
948 None,
949 creator,
950 "Plainte textuelle".to_string(),
951 "Aucune photo dispo".to_string(),
952 TicketCategory::Security,
953 TicketPriority::Medium,
954 TicketKind::Complaint,
955 Some(TicketSeverity::Normal),
956 None,
957 Vec::new(),
958 Vec::new(),
959 )
960 .expect("Complaint without evidence/witnesses must be allowed (text-only)");
961 assert!(t.evidence_attachments.is_empty());
962 }
963
964 #[test]
965 fn edge_exactly_max_evidence_attachments_is_accepted() {
966 let evidence: Vec<String> = (0..MAX_EVIDENCE_ATTACHMENTS)
967 .map(|i| format!("https://e.test/{i}"))
968 .collect();
969 let t = Ticket::new_with_kind(
970 Uuid::new_v4(),
971 Uuid::new_v4(),
972 None,
973 Uuid::new_v4(),
974 "T".to_string(),
975 "D".to_string(),
976 TicketCategory::Other,
977 TicketPriority::Low,
978 TicketKind::Complaint,
979 Some(TicketSeverity::Low),
980 None,
981 evidence,
982 Vec::new(),
983 );
984 assert!(
985 t.is_ok(),
986 "exactly {} evidence URLs is on-bound",
987 MAX_EVIDENCE_ATTACHMENTS
988 );
989 }
990
991 #[test]
992 fn edge_one_over_max_evidence_attachments_is_rejected() {
993 let evidence: Vec<String> = (0..(MAX_EVIDENCE_ATTACHMENTS + 1))
994 .map(|i| format!("https://e.test/{i}"))
995 .collect();
996 let err = Ticket::new_with_kind(
997 Uuid::new_v4(),
998 Uuid::new_v4(),
999 None,
1000 Uuid::new_v4(),
1001 "T".to_string(),
1002 "D".to_string(),
1003 TicketCategory::Other,
1004 TicketPriority::Low,
1005 TicketKind::Complaint,
1006 Some(TicketSeverity::Low),
1007 None,
1008 evidence,
1009 Vec::new(),
1010 )
1011 .unwrap_err();
1012 assert!(matches!(err, AppError::Validation(_)));
1013 }
1014
1015 #[test]
1016 fn edge_is_editable_at_just_under_window_returns_true() {
1017 let mut t = make_request_ticket();
1018 let now = Utc::now();
1019 t.created_at = now;
1020 let probe = now + chrono::Duration::seconds(4 * 60 + 59);
1022 assert!(t.is_editable_at(probe));
1023 }
1024
1025 #[test]
1026 fn edge_is_editable_at_just_past_window_returns_false() {
1027 let mut t = make_request_ticket();
1028 let now = Utc::now();
1029 t.created_at = now;
1030 let probe = now + chrono::Duration::seconds(5 * 60 + 1);
1032 assert!(!t.is_editable_at(probe));
1033 }
1034
1035 #[test]
1038 fn security_duplicate_witnesses_are_rejected() {
1039 let dup = Uuid::new_v4();
1040 let err = Ticket::new_with_kind(
1041 Uuid::new_v4(),
1042 Uuid::new_v4(),
1043 None,
1044 Uuid::new_v4(),
1045 "T".to_string(),
1046 "D".to_string(),
1047 TicketCategory::Other,
1048 TicketPriority::Low,
1049 TicketKind::Complaint,
1050 Some(TicketSeverity::Normal),
1051 None,
1052 Vec::new(),
1053 vec![dup, dup],
1054 )
1055 .unwrap_err();
1056 assert!(matches!(err, AppError::Validation(_)));
1057 }
1058
1059 #[test]
1060 fn security_request_with_severity_is_allowed_severity_not_exclusive_to_complaint() {
1061 let t = Ticket::new_with_kind(
1063 Uuid::new_v4(),
1064 Uuid::new_v4(),
1065 None,
1066 Uuid::new_v4(),
1067 "T".to_string(),
1068 "D".to_string(),
1069 TicketCategory::Heating,
1070 TicketPriority::Medium,
1071 TicketKind::Request,
1072 Some(TicketSeverity::High),
1073 None,
1074 Vec::new(),
1075 Vec::new(),
1076 )
1077 .expect("Request + severity must be accepted (severity not exclusive)");
1078 assert_eq!(t.kind, TicketKind::Request);
1079 assert_eq!(t.severity, Some(TicketSeverity::High));
1080 }
1081
1082 #[test]
1083 fn security_creator_listed_as_witness_is_rejected() {
1084 let creator = Uuid::new_v4();
1085 let err = Ticket::new_with_kind(
1086 Uuid::new_v4(),
1087 Uuid::new_v4(),
1088 None,
1089 creator,
1090 "T".to_string(),
1091 "D".to_string(),
1092 TicketCategory::Other,
1093 TicketPriority::Low,
1094 TicketKind::Complaint,
1095 Some(TicketSeverity::Normal),
1096 None,
1097 Vec::new(),
1098 vec![creator],
1099 )
1100 .unwrap_err();
1101 assert!(matches!(err, AppError::Validation(_)));
1102 }
1103
1104 #[test]
1107 fn negative_complaint_without_severity_is_rejected() {
1108 let err = Ticket::new_with_kind(
1109 Uuid::new_v4(),
1110 Uuid::new_v4(),
1111 None,
1112 Uuid::new_v4(),
1113 "T".to_string(),
1114 "D".to_string(),
1115 TicketCategory::Other,
1116 TicketPriority::Low,
1117 TicketKind::Complaint,
1118 None, None,
1120 Vec::new(),
1121 Vec::new(),
1122 )
1123 .unwrap_err();
1124 assert!(matches!(err, AppError::Validation(_)));
1125 }
1126
1127 #[test]
1128 fn negative_eleven_witnesses_is_rejected() {
1129 let witnesses: Vec<Uuid> = (0..(MAX_WITNESSES + 1)).map(|_| Uuid::new_v4()).collect();
1130 let err = Ticket::new_with_kind(
1131 Uuid::new_v4(),
1132 Uuid::new_v4(),
1133 None,
1134 Uuid::new_v4(),
1135 "T".to_string(),
1136 "D".to_string(),
1137 TicketCategory::Other,
1138 TicketPriority::Low,
1139 TicketKind::Complaint,
1140 Some(TicketSeverity::Low),
1141 None,
1142 Vec::new(),
1143 witnesses,
1144 )
1145 .unwrap_err();
1146 assert!(matches!(err, AppError::Validation(_)));
1147 }
1148
1149 #[test]
1150 fn negative_empty_evidence_url_is_rejected() {
1151 let err = Ticket::new_with_kind(
1152 Uuid::new_v4(),
1153 Uuid::new_v4(),
1154 None,
1155 Uuid::new_v4(),
1156 "T".to_string(),
1157 "D".to_string(),
1158 TicketCategory::Other,
1159 TicketPriority::Low,
1160 TicketKind::Complaint,
1161 Some(TicketSeverity::Low),
1162 None,
1163 vec![" ".to_string()],
1164 Vec::new(),
1165 )
1166 .unwrap_err();
1167 assert!(matches!(err, AppError::Validation(_)));
1168 }
1169
1170 #[test]
1171 fn negative_invalid_kind_string_is_rejected() {
1172 use std::str::FromStr;
1173 let err = TicketKind::from_str("incident").unwrap_err();
1174 assert!(matches!(err, AppError::Validation(_)));
1175 }
1176
1177 #[test]
1178 fn negative_invalid_severity_string_is_rejected() {
1179 use std::str::FromStr;
1180 let err = TicketSeverity::from_str("urgent").unwrap_err();
1181 assert!(matches!(err, AppError::Validation(_)));
1182 }
1183}