Skip to main content

koprogo_api/domain/economie_circulaire/
ticket.rs

1use crate::application::error::AppError;
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::collections::HashSet;
5use uuid::Uuid;
6
7/// Story 3.6 (INV-24) — Ticket is locked to direct edits beyond this window
8/// after creation. Subsequent changes go through workflow endpoints (assign,
9/// resolve, …) which carry their own typed errors.
10pub const TICKET_EDIT_WINDOW_MINUTES: i64 = 5;
11
12/// Story 3.6 (FR31) — Hard cap on the number of evidence URLs accepted on a
13/// single ticket (anti-abuse + UI sanity).
14pub const MAX_EVIDENCE_ATTACHMENTS: usize = 10;
15
16/// Story 3.6 (FR31) — Hard cap on the number of witnesses on a single ticket.
17pub const MAX_WITNESSES: usize = 10;
18
19/// Story 3.6 (FR31) — Distinguishes a maintenance request (default) from a
20/// formal complaint (incident report → triage / mediation workflow).
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, utoipa::ToSchema)]
22#[serde(rename_all = "lowercase")]
23pub enum TicketKind {
24    /// Standard maintenance request (backward-compat default).
25    #[default]
26    Request,
27    /// Formal complaint / incident report (community moderation workflow).
28    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/// Story 3.6 (FR31) — Severity tier for ticket triage. Ordered so callers
56/// can compare (e.g. `>= High`) when deciding alerting / SLA targets.
57#[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/// Ticket Category - Types of maintenance requests
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
98pub enum TicketCategory {
99    Plumbing,    // Plomberie
100    Electrical,  // Électricité
101    Heating,     // Chauffage
102    CommonAreas, // Parties communes
103    Elevator,    // Ascenseur
104    Security,    // Sécurité
105    Cleaning,    // Nettoyage
106    Landscaping, // Espaces verts
107    Other,       // Autre
108}
109
110/// Ticket Priority
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd, utoipa::ToSchema)]
112pub enum TicketPriority {
113    Low,      // Basse
114    Medium,   // Moyenne
115    High,     // Haute
116    Critical, // Critique/Urgente
117}
118
119/// Ticket Status - Workflow states
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
121pub enum TicketStatus {
122    Open,       // Ouvert (nouveau ticket)
123    InProgress, // En cours de traitement
124    Resolved,   // Résolu (intervention terminée)
125    Closed,     // Fermé (validé par le demandeur)
126    Cancelled,  // Annulé
127}
128
129/// Ticket Entity - Maintenance request from owners
130///
131/// Represents a maintenance request (ticket) submitted by a co-owner
132/// for issues in the building (plumbing, electrical, etc.).
133#[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>, // If specific to a unit, None for common areas
139    pub created_by: Uuid,      // Owner who created the ticket
140    pub assigned_to: Option<Uuid>, // User (syndic, contractor) assigned
141    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>, // Notes from resolver
147    pub work_order_sent_at: Option<DateTime<Utc>>, // When magic link PWA was sent to contractor (Issue #309)
148    /// Story 3.6 (FR31) — Request (default) vs Complaint. Drives triage UI and
149    /// (for Complaint) the severity invariant.
150    #[serde(default)]
151    pub kind: TicketKind,
152    /// Story 3.6 (FR31) — Triage tier. MUST be `Some` when `kind == Complaint`
153    /// (enforced by `validate_invariants`).
154    #[serde(default)]
155    pub severity: Option<TicketSeverity>,
156    /// Story 3.6 (FR31) — Optional incident timestamp (legal evidence). Free
157    /// of an upper bound here; the use-case layer may compare against
158    /// `created_at` if needed.
159    #[serde(default)]
160    pub incident_date: Option<DateTime<Utc>>,
161    /// Story 3.6 (FR31) — Up to [`MAX_EVIDENCE_ATTACHMENTS`] URLs / object-
162    /// store references attached as proof.
163    #[serde(default)]
164    pub evidence_attachments: Vec<String>,
165    /// Story 3.6 (FR31) — Up to [`MAX_WITNESSES`] user_ids witnesses. No
166    /// duplicates (enforced by `validate_invariants`).
167    #[serde(default)]
168    pub witnesses: Vec<Uuid>,
169    /// Story 3.7 (FR32) — Deadline before which the syndic must respond to
170    /// the ticket. Computed by [`Ticket::new_with_kind`] from `severity` via
171    /// [`crate::domain::entities::sla_window_for_severity`]. `None` for
172    /// pre-3.7 tickets and for tickets without a severity tier.
173    #[serde(default)]
174    pub sla_due_at: Option<DateTime<Utc>>,
175    /// Story 3.7 (FR32) — Timestamp at which the SLA was "consumed": either
176    /// because a syndic responded in time (pre-empts escalation) or because
177    /// the cron job marked the ticket as overdue. `None` while the SLA is
178    /// still open.
179    #[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    /// Create a new ticket
189    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        // Validation
200        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            // Story 3.7 — legacy `new()` has no severity → no SLA.
238            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    /// Story 3.6 (FR31) — Constructor supporting the full Story 3.6 surface
248    /// (kind / severity / incident_date / evidence / witnesses). Returns
249    /// `AppError` natively so the use-case layer doesn't have to bridge a
250    /// legacy `String` error.
251    ///
252    /// Invariants enforced here in addition to the legacy `new()` checks:
253    /// - `kind == Complaint` → `severity.is_some()`
254    /// - `evidence_attachments.len() <= MAX_EVIDENCE_ATTACHMENTS`
255    /// - `witnesses.len() <= MAX_WITNESSES`
256    /// - no duplicate witnesses
257    /// - `created_by` not in `witnesses` (self-witnessing is incoherent)
258    #[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        // Reuse the legacy validation surface (title/description) to stay DRY.
275        // Bridge its `String` errors into `AppError::Validation` so the caller
276        // sees a 400, not a 500.
277        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        // Story 3.7 — derive `sla_due_at` from severity at create-time. We
296        // compute it on every ticket carrying a severity (Request or
297        // Complaint), since the brief allows severity on Request too.
298        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    // ------------------------------------------------------------------
307    // Story 3.6 (INV-24) — Immutability window.
308    // ------------------------------------------------------------------
309
310    /// Returns `true` when `t` falls strictly within the
311    /// [`TICKET_EDIT_WINDOW_MINUTES`] window after `created_at`.
312    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    /// Convenience wrapper using `Utc::now()` — see [`Self::is_editable_at`].
318    pub fn is_currently_editable(&self) -> bool {
319        self.is_editable_at(Utc::now())
320    }
321
322    /// Story 3.6 (FR31) — Validate every Complaint/evidence/witness invariant
323    /// at once. Used by both constructors and the PATCH use-case (after the
324    /// caller mutates fields) so the entity never escapes in an inconsistent
325    /// state.
326    ///
327    /// Returns `AppError::Validation` with a self-describing message on the
328    /// first violation found.
329    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        // Evidence URLs: trim sanity (empty strings forbidden).
361        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    /// Assign ticket to a user (syndic or contractor)
374    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        // Auto-transition to InProgress if still Open
383        if self.status == TicketStatus::Open {
384            self.status = TicketStatus::InProgress;
385        }
386
387        Ok(())
388    }
389
390    /// Mark ticket as in progress
391    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(()), // Already in progress
399            _ => Err(format!(
400                "Cannot start work on ticket in status {:?}",
401                self.status
402            )),
403        }
404    }
405
406    /// Resolve ticket (work completed)
407    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                // Allow updating resolution notes
426                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    /// Close ticket (validation by requester or syndic)
435    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(()), // Already closed
444            _ => Err(format!(
445                "Cannot close ticket in status {:?}. Must be Resolved first.",
446                self.status
447            )),
448        }
449    }
450
451    /// Cancel ticket
452    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    /// Reopen ticket (if incorrectly resolved)
469    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    /// Send work order to contractor (magic link PWA) - Issue #309
492    /// Validates ticket is in InProgress status (must be assigned)
493    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    /// Check if ticket is overdue (open for more than X days)
513    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); // Auto-transitioned
585    }
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        // Simulate old ticket (10 days ago)
716        ticket.created_at = Utc::now() - chrono::Duration::days(10);
717
718        assert!(ticket.is_overdue(5));
719        assert!(!ticket.is_overdue(15));
720
721        // Closed tickets are never overdue
722        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        // Assign contractor
741        ticket.assign(Uuid::new_v4()).unwrap();
742        assert_eq!(ticket.status, TicketStatus::InProgress);
743
744        // Send work order
745        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        // Try to send without assigning
765        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        // Try to send work order on resolved ticket
790        let result = ticket.send_work_order_to_contractor();
791        assert!(result.is_err());
792        assert!(result.unwrap_err().contains("InProgress status"));
793    }
794
795    // ========================================================================
796    // Story 3.6 — Complaint / evidence / witnesses / INV-24 (FR31)
797    // 4-cat taxonomy: @happy / @edge / @security / @negative
798    // ========================================================================
799
800    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    // ---- @happy -------------------------------------------------------------
815
816    #[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        // Story 3.7 — no severity → no SLA.
826        assert!(t.sla_due_at.is_none());
827        assert!(t.sla_escalated_at.is_none());
828    }
829
830    // ---- Story 3.7 — SLA computation at create-time -------------------------
831
832    #[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        // 24h window for Critical (cf. sla_window_for_severity).
853        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        // Request without severity — pre-3.7 retro-compat path.
860        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        // Ordering used by triage code: alert if `severity >= High`.
934        assert!(TicketSeverity::Critical > TicketSeverity::High);
935        assert!(TicketSeverity::High > TicketSeverity::Normal);
936        assert!(TicketSeverity::Normal > TicketSeverity::Low);
937    }
938
939    // ---- @edge --------------------------------------------------------------
940
941    #[test]
942    fn edge_complaint_with_no_evidence_is_allowed_text_only_path() {
943        // The UI flags "no evidence attached" but the entity accepts it.
944        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        // 4m59s
1021        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        // 5m1s — INV-24 locks the ticket
1031        let probe = now + chrono::Duration::seconds(5 * 60 + 1);
1032        assert!(!t.is_editable_at(probe));
1033    }
1034
1035    // ---- @security ----------------------------------------------------------
1036
1037    #[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        // FR31 — severity may decorate a Request too (e.g. internal triage).
1062        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    // ---- @negative ----------------------------------------------------------
1105
1106    #[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, // ← missing
1119            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}