Skip to main content

koprogo_api/application/use_cases/
ticket_use_cases.rs

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    /// Create a new maintenance request ticket.
21    ///
22    /// Backward-compatible entry point. Pre-Story-3.6 clients keep working
23    /// (kind defaults to `Request`, complaint fields default to empty).
24    /// Post-3.6 clients pass `kind = Some(Complaint)` + `severity = Some(_)`
25    /// and the request is validated by [`Ticket::new_with_kind`].
26    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        // The Story 3.6 surface (kind / severity / incident_date / evidence /
35        // witnesses) is validated by `new_with_kind`. Pre-3.6 callers send
36        // empty defaults so the entity-level invariants stay green.
37        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        // Bridge AppError → String for the retro-compat surface (cluster #555
53        // tracks the full migration; here we only touch the surface the story
54        // requires, per Phase A scope).
55        .map_err(|e| e.to_string())?;
56
57        let created = self.ticket_repository.create(&ticket).await?;
58        Ok(TicketResponse::from(created))
59    }
60
61    /// Story 3.6 (FR31 / INV-24) — Patch the editable fields of a ticket.
62    ///
63    /// Native `AppError` surface (no legacy `String` bridging). Two failure
64    /// modes that must be HTTP-distinct:
65    ///
66    /// - `AppError::NotFound` (404) — unknown id;
67    /// - `AppError::TicketImmutable` (403) — INV-24 window elapsed;
68    /// - `AppError::Validation` (400) — invariant violation on the new state.
69    ///
70    /// Workflow transitions (assign / resolve / close / cancel / reopen) are
71    /// intentionally NOT exposed here — they have their own typed endpoints
72    /// to preserve audit fidelity (PUT /tickets/{id}/{transition}).
73    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        // Re-validate the post-mutation state. Invariant violations bubble up
136        // as `AppError::Validation` → 400.
137        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    /// Get a ticket by ID
148    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    /// List all tickets for a building
156    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    /// List all tickets for an organization
165    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    /// List tickets created by a specific owner
177    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    /// List tickets assigned to a specific user (syndic/contractor)
186    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    /// List tickets by status for a building
198    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    /// Assign a ticket to a user (syndic or contractor)
211    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    /// Start working on a ticket (transition to InProgress)
229    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    /// Resolve a ticket (mark as work completed)
243    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    /// Close a ticket (final validation by requester or syndic)
261    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    /// Cancel a ticket with a reason
275    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    /// Reopen a ticket (if incorrectly resolved)
293    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    /// Delete a ticket (only allowed for Open tickets)
311    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        // Only allow deletion of Open tickets
319        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    /// Get ticket statistics for a building
327    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        // Domain has no separate Assigned state; surfaced as 0 for frontend display.
342        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    /// Check for overdue tickets (open for more than specified days)
385    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    /// Get ticket statistics for an organization (all buildings)
402    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    /// Check for overdue tickets in organization (all buildings)
459    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    /// Send work order to contractor via PWA magic link (Issue #309)
479    /// Validates ticket is in InProgress status and sends work order notification
480    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/// Ticket statistics for a building
495#[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    // ── Manual mock for TicketRepository ──────────────────────────────
524
525    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    // ── Helpers ──────────────────────────────────────────────────────
672
673    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            // Story 3.6 — defaults preserve pre-3.6 behavior.
686            kind: None,
687            severity: None,
688            incident_date: None,
689            evidence_attachments: Vec::new(),
690            witnesses: Vec::new(),
691        }
692    }
693
694    /// Helper: create a ticket through the use case and return its id
695    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    // ── Tests ────────────────────────────────────────────────────────
709
710    #[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        // Transition to InProgress first
808        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        // Full lifecycle: Open -> InProgress -> Resolved -> Closed
835        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        // Attempt to close directly from Open (skipping Resolved)
864        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        // Open -> InProgress -> Resolved
904        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        // Reopen
916        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        // Ticket is Open, cannot reopen
943        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        // Assign contractor (auto-transitions to InProgress)
964        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        // Send work order
976        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        // Ticket is Open (not InProgress), should fail
994        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        // Create a ticket and manually age it
1010        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        // Manually set created_at to 10 days ago in the mock store
1017        {
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        // With a longer threshold, ticket should not be overdue
1029        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        // Create 3 tickets, transition them to different states
1045        let _t1 = create_ticket_helper(&use_cases, org_id, building_id).await; // stays Open
1046        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        // t2 -> InProgress -> Resolved
1050        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        // t3 -> Cancelled
1062        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); // t1
1076        assert_eq!(stats.in_progress, 0);
1077        assert_eq!(stats.resolved, 1); // t2
1078        assert_eq!(stats.closed, 0);
1079        assert_eq!(stats.cancelled, 1); // t3
1080    }
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        // Verify ticket no longer exists
1097        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        // Transition to InProgress
1111        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    // ========================================================================
1122    // Story 3.6 — Complaint creation + INV-24 PATCH immutability (FR31)
1123    // 4-cat taxonomy: @happy / @edge / @security / @negative
1124    // ========================================================================
1125
1126    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    // ---- @happy -------------------------------------------------------------
1151
1152    #[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    // ---- @edge --------------------------------------------------------------
1195
1196    #[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    // ---- @security ----------------------------------------------------------
1217
1218    #[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        // Manually age the ticket past INV-24 in the mock store.
1228        {
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    // ---- @negative ----------------------------------------------------------
1247
1248    #[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}