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,
4};
5use crate::application::ports::TicketRepository;
6use crate::domain::entities::{Ticket, TicketStatus};
7use std::sync::Arc;
8use uuid::Uuid;
9
10pub struct TicketUseCases {
11    ticket_repository: Arc<dyn TicketRepository>,
12}
13
14impl TicketUseCases {
15    pub fn new(ticket_repository: Arc<dyn TicketRepository>) -> Self {
16        Self { ticket_repository }
17    }
18
19    /// Create a new maintenance request ticket
20    pub async fn create_ticket(
21        &self,
22        organization_id: Uuid,
23        created_by: Uuid,
24        request: CreateTicketRequest,
25    ) -> Result<TicketResponse, String> {
26        let ticket = Ticket::new(
27            organization_id,
28            request.building_id,
29            request.unit_id,
30            created_by,
31            request.title,
32            request.description,
33            request.category,
34            request.priority,
35        )?;
36
37        let created = self.ticket_repository.create(&ticket).await?;
38        Ok(TicketResponse::from(created))
39    }
40
41    /// Get a ticket by ID
42    pub async fn get_ticket(&self, id: Uuid) -> Result<Option<TicketResponse>, String> {
43        match self.ticket_repository.find_by_id(id).await? {
44            Some(ticket) => Ok(Some(TicketResponse::from(ticket))),
45            None => Ok(None),
46        }
47    }
48
49    /// List all tickets for a building
50    pub async fn list_tickets_by_building(
51        &self,
52        building_id: Uuid,
53    ) -> Result<Vec<TicketResponse>, String> {
54        let tickets = self.ticket_repository.find_by_building(building_id).await?;
55        Ok(tickets.into_iter().map(TicketResponse::from).collect())
56    }
57
58    /// List all tickets for an organization
59    pub async fn list_tickets_by_organization(
60        &self,
61        organization_id: Uuid,
62    ) -> Result<Vec<TicketResponse>, String> {
63        let tickets = self
64            .ticket_repository
65            .find_by_organization(organization_id)
66            .await?;
67        Ok(tickets.into_iter().map(TicketResponse::from).collect())
68    }
69
70    /// List tickets created by a specific owner
71    pub async fn list_my_tickets(&self, created_by: Uuid) -> Result<Vec<TicketResponse>, String> {
72        let tickets = self
73            .ticket_repository
74            .find_by_created_by(created_by)
75            .await?;
76        Ok(tickets.into_iter().map(TicketResponse::from).collect())
77    }
78
79    /// List tickets assigned to a specific user (syndic/contractor)
80    pub async fn list_assigned_tickets(
81        &self,
82        assigned_to: Uuid,
83    ) -> Result<Vec<TicketResponse>, String> {
84        let tickets = self
85            .ticket_repository
86            .find_by_assigned_to(assigned_to)
87            .await?;
88        Ok(tickets.into_iter().map(TicketResponse::from).collect())
89    }
90
91    /// List tickets by status for a building
92    pub async fn list_tickets_by_status(
93        &self,
94        building_id: Uuid,
95        status: TicketStatus,
96    ) -> Result<Vec<TicketResponse>, String> {
97        let tickets = self
98            .ticket_repository
99            .find_by_status(building_id, status)
100            .await?;
101        Ok(tickets.into_iter().map(TicketResponse::from).collect())
102    }
103
104    /// Assign a ticket to a user (syndic or contractor)
105    pub async fn assign_ticket(
106        &self,
107        id: Uuid,
108        request: AssignTicketRequest,
109    ) -> Result<TicketResponse, String> {
110        let mut ticket = self
111            .ticket_repository
112            .find_by_id(id)
113            .await?
114            .ok_or_else(|| "Ticket not found".to_string())?;
115
116        ticket.assign(request.assigned_to)?;
117
118        let updated = self.ticket_repository.update(&ticket).await?;
119        Ok(TicketResponse::from(updated))
120    }
121
122    /// Start working on a ticket (transition to InProgress)
123    pub async fn start_work(&self, id: Uuid) -> Result<TicketResponse, String> {
124        let mut ticket = self
125            .ticket_repository
126            .find_by_id(id)
127            .await?
128            .ok_or_else(|| "Ticket not found".to_string())?;
129
130        ticket.start_work()?;
131
132        let updated = self.ticket_repository.update(&ticket).await?;
133        Ok(TicketResponse::from(updated))
134    }
135
136    /// Resolve a ticket (mark as work completed)
137    pub async fn resolve_ticket(
138        &self,
139        id: Uuid,
140        request: ResolveTicketRequest,
141    ) -> Result<TicketResponse, String> {
142        let mut ticket = self
143            .ticket_repository
144            .find_by_id(id)
145            .await?
146            .ok_or_else(|| "Ticket not found".to_string())?;
147
148        ticket.resolve(request.resolution_notes)?;
149
150        let updated = self.ticket_repository.update(&ticket).await?;
151        Ok(TicketResponse::from(updated))
152    }
153
154    /// Close a ticket (final validation by requester or syndic)
155    pub async fn close_ticket(&self, id: Uuid) -> Result<TicketResponse, String> {
156        let mut ticket = self
157            .ticket_repository
158            .find_by_id(id)
159            .await?
160            .ok_or_else(|| "Ticket not found".to_string())?;
161
162        ticket.close()?;
163
164        let updated = self.ticket_repository.update(&ticket).await?;
165        Ok(TicketResponse::from(updated))
166    }
167
168    /// Cancel a ticket with a reason
169    pub async fn cancel_ticket(
170        &self,
171        id: Uuid,
172        request: CancelTicketRequest,
173    ) -> Result<TicketResponse, String> {
174        let mut ticket = self
175            .ticket_repository
176            .find_by_id(id)
177            .await?
178            .ok_or_else(|| "Ticket not found".to_string())?;
179
180        ticket.cancel(request.reason)?;
181
182        let updated = self.ticket_repository.update(&ticket).await?;
183        Ok(TicketResponse::from(updated))
184    }
185
186    /// Reopen a ticket (if incorrectly resolved)
187    pub async fn reopen_ticket(
188        &self,
189        id: Uuid,
190        request: ReopenTicketRequest,
191    ) -> Result<TicketResponse, String> {
192        let mut ticket = self
193            .ticket_repository
194            .find_by_id(id)
195            .await?
196            .ok_or_else(|| "Ticket not found".to_string())?;
197
198        ticket.reopen(request.reason)?;
199
200        let updated = self.ticket_repository.update(&ticket).await?;
201        Ok(TicketResponse::from(updated))
202    }
203
204    /// Delete a ticket (only allowed for Open tickets)
205    pub async fn delete_ticket(&self, id: Uuid) -> Result<bool, String> {
206        let ticket = self
207            .ticket_repository
208            .find_by_id(id)
209            .await?
210            .ok_or_else(|| "Ticket not found".to_string())?;
211
212        // Only allow deletion of Open tickets
213        if ticket.status != TicketStatus::Open {
214            return Err("Can only delete tickets with Open status".to_string());
215        }
216
217        self.ticket_repository.delete(id).await
218    }
219
220    /// Get ticket statistics for a building
221    pub async fn get_ticket_statistics(
222        &self,
223        building_id: Uuid,
224    ) -> Result<TicketStatistics, String> {
225        let total = self
226            .ticket_repository
227            .count_by_building(building_id)
228            .await?;
229
230        let open = self
231            .ticket_repository
232            .count_by_status(building_id, TicketStatus::Open)
233            .await?;
234
235        // Domain has no separate Assigned state; surfaced as 0 for frontend display.
236        let assigned: i64 = 0;
237
238        let in_progress = self
239            .ticket_repository
240            .count_by_status(building_id, TicketStatus::InProgress)
241            .await?;
242
243        let resolved = self
244            .ticket_repository
245            .count_by_status(building_id, TicketStatus::Resolved)
246            .await?;
247
248        let closed = self
249            .ticket_repository
250            .count_by_status(building_id, TicketStatus::Closed)
251            .await?;
252
253        let cancelled = self
254            .ticket_repository
255            .count_by_status(building_id, TicketStatus::Cancelled)
256            .await?;
257
258        let overdue_tickets = self
259            .ticket_repository
260            .find_by_building(building_id)
261            .await?
262            .iter()
263            .filter(|t| t.is_overdue(7))
264            .count() as i64;
265
266        Ok(TicketStatistics {
267            total,
268            open,
269            assigned,
270            in_progress,
271            resolved,
272            closed,
273            cancelled,
274            overdue_tickets,
275        })
276    }
277
278    /// Check for overdue tickets (open for more than specified days)
279    pub async fn get_overdue_tickets(
280        &self,
281        building_id: Uuid,
282        max_days: i64,
283    ) -> Result<Vec<TicketResponse>, String> {
284        let tickets = self.ticket_repository.find_by_building(building_id).await?;
285
286        let overdue: Vec<TicketResponse> = tickets
287            .into_iter()
288            .filter(|ticket| ticket.is_overdue(max_days))
289            .map(TicketResponse::from)
290            .collect();
291
292        Ok(overdue)
293    }
294
295    /// Get ticket statistics for an organization (all buildings)
296    pub async fn get_ticket_statistics_by_organization(
297        &self,
298        organization_id: Uuid,
299    ) -> Result<TicketStatistics, String> {
300        let total = self
301            .ticket_repository
302            .count_by_organization(organization_id)
303            .await?;
304
305        let open = self
306            .ticket_repository
307            .count_by_organization_and_status(organization_id, TicketStatus::Open)
308            .await?;
309
310        let assigned: i64 = 0;
311
312        let in_progress = self
313            .ticket_repository
314            .count_by_organization_and_status(organization_id, TicketStatus::InProgress)
315            .await?;
316
317        let resolved = self
318            .ticket_repository
319            .count_by_organization_and_status(organization_id, TicketStatus::Resolved)
320            .await?;
321
322        let closed = self
323            .ticket_repository
324            .count_by_organization_and_status(organization_id, TicketStatus::Closed)
325            .await?;
326
327        let cancelled = self
328            .ticket_repository
329            .count_by_organization_and_status(organization_id, TicketStatus::Cancelled)
330            .await?;
331
332        let overdue_tickets = self
333            .ticket_repository
334            .find_by_organization(organization_id)
335            .await?
336            .iter()
337            .filter(|t| t.is_overdue(7))
338            .count() as i64;
339
340        Ok(TicketStatistics {
341            total,
342            open,
343            assigned,
344            in_progress,
345            resolved,
346            closed,
347            cancelled,
348            overdue_tickets,
349        })
350    }
351
352    /// Check for overdue tickets in organization (all buildings)
353    pub async fn get_overdue_tickets_by_organization(
354        &self,
355        organization_id: Uuid,
356        max_days: i64,
357    ) -> Result<Vec<TicketResponse>, String> {
358        let tickets = self
359            .ticket_repository
360            .find_by_organization(organization_id)
361            .await?;
362
363        let overdue: Vec<TicketResponse> = tickets
364            .into_iter()
365            .filter(|ticket| ticket.is_overdue(max_days))
366            .map(TicketResponse::from)
367            .collect();
368
369        Ok(overdue)
370    }
371
372    /// Send work order to contractor via PWA magic link (Issue #309)
373    /// Validates ticket is in InProgress status and sends work order notification
374    pub async fn send_work_order(&self, ticket_id: Uuid) -> Result<TicketResponse, String> {
375        let mut ticket = self
376            .ticket_repository
377            .find_by_id(ticket_id)
378            .await?
379            .ok_or_else(|| "Ticket not found".to_string())?;
380
381        ticket.send_work_order_to_contractor()?;
382        let updated = self.ticket_repository.update(&ticket).await?;
383
384        Ok(TicketResponse::from(updated))
385    }
386}
387
388/// Ticket statistics for a building
389#[derive(Debug, serde::Serialize)]
390pub struct TicketStatistics {
391    #[serde(rename = "total_tickets")]
392    pub total: i64,
393    #[serde(rename = "open_tickets")]
394    pub open: i64,
395    #[serde(rename = "assigned_tickets")]
396    pub assigned: i64,
397    #[serde(rename = "in_progress_tickets")]
398    pub in_progress: i64,
399    #[serde(rename = "resolved_tickets")]
400    pub resolved: i64,
401    #[serde(rename = "closed_tickets")]
402    pub closed: i64,
403    #[serde(rename = "cancelled_tickets")]
404    pub cancelled: i64,
405    pub overdue_tickets: i64,
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use crate::application::ports::TicketRepository;
412    use crate::domain::entities::{Ticket, TicketCategory, TicketPriority, TicketStatus};
413    use async_trait::async_trait;
414    use std::collections::HashMap;
415    use std::sync::Mutex;
416
417    // ── Manual mock for TicketRepository ──────────────────────────────
418
419    struct MockTicketRepository {
420        tickets: Mutex<HashMap<Uuid, Ticket>>,
421    }
422
423    impl MockTicketRepository {
424        fn new() -> Self {
425            Self {
426                tickets: Mutex::new(HashMap::new()),
427            }
428        }
429    }
430
431    #[async_trait]
432    impl TicketRepository for MockTicketRepository {
433        async fn create(&self, ticket: &Ticket) -> Result<Ticket, String> {
434            self.tickets
435                .lock()
436                .unwrap()
437                .insert(ticket.id, ticket.clone());
438            Ok(ticket.clone())
439        }
440
441        async fn find_by_id(&self, id: Uuid) -> Result<Option<Ticket>, String> {
442            Ok(self.tickets.lock().unwrap().get(&id).cloned())
443        }
444
445        async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Ticket>, String> {
446            Ok(self
447                .tickets
448                .lock()
449                .unwrap()
450                .values()
451                .filter(|t| t.building_id == building_id)
452                .cloned()
453                .collect())
454        }
455
456        async fn find_by_organization(&self, organization_id: Uuid) -> Result<Vec<Ticket>, String> {
457            Ok(self
458                .tickets
459                .lock()
460                .unwrap()
461                .values()
462                .filter(|t| t.organization_id == organization_id)
463                .cloned()
464                .collect())
465        }
466
467        async fn find_by_created_by(&self, created_by: Uuid) -> Result<Vec<Ticket>, String> {
468            Ok(self
469                .tickets
470                .lock()
471                .unwrap()
472                .values()
473                .filter(|t| t.created_by == created_by)
474                .cloned()
475                .collect())
476        }
477
478        async fn find_by_assigned_to(&self, assigned_to: Uuid) -> Result<Vec<Ticket>, String> {
479            Ok(self
480                .tickets
481                .lock()
482                .unwrap()
483                .values()
484                .filter(|t| t.assigned_to == Some(assigned_to))
485                .cloned()
486                .collect())
487        }
488
489        async fn find_by_status(
490            &self,
491            building_id: Uuid,
492            status: TicketStatus,
493        ) -> Result<Vec<Ticket>, String> {
494            Ok(self
495                .tickets
496                .lock()
497                .unwrap()
498                .values()
499                .filter(|t| t.building_id == building_id && t.status == status)
500                .cloned()
501                .collect())
502        }
503
504        async fn update(&self, ticket: &Ticket) -> Result<Ticket, String> {
505            self.tickets
506                .lock()
507                .unwrap()
508                .insert(ticket.id, ticket.clone());
509            Ok(ticket.clone())
510        }
511
512        async fn delete(&self, id: Uuid) -> Result<bool, String> {
513            Ok(self.tickets.lock().unwrap().remove(&id).is_some())
514        }
515
516        async fn count_by_building(&self, building_id: Uuid) -> Result<i64, String> {
517            Ok(self
518                .tickets
519                .lock()
520                .unwrap()
521                .values()
522                .filter(|t| t.building_id == building_id)
523                .count() as i64)
524        }
525
526        async fn count_by_status(
527            &self,
528            building_id: Uuid,
529            status: TicketStatus,
530        ) -> Result<i64, String> {
531            Ok(self
532                .tickets
533                .lock()
534                .unwrap()
535                .values()
536                .filter(|t| t.building_id == building_id && t.status == status)
537                .count() as i64)
538        }
539
540        async fn count_by_organization(&self, organization_id: Uuid) -> Result<i64, String> {
541            Ok(self
542                .tickets
543                .lock()
544                .unwrap()
545                .values()
546                .filter(|t| t.organization_id == organization_id)
547                .count() as i64)
548        }
549
550        async fn count_by_organization_and_status(
551            &self,
552            organization_id: Uuid,
553            status: TicketStatus,
554        ) -> Result<i64, String> {
555            Ok(self
556                .tickets
557                .lock()
558                .unwrap()
559                .values()
560                .filter(|t| t.organization_id == organization_id && t.status == status)
561                .count() as i64)
562        }
563    }
564
565    // ── Helpers ──────────────────────────────────────────────────────
566
567    fn make_use_cases(repo: Arc<dyn TicketRepository>) -> TicketUseCases {
568        TicketUseCases::new(repo)
569    }
570
571    fn make_create_request(building_id: Uuid) -> CreateTicketRequest {
572        CreateTicketRequest {
573            building_id,
574            unit_id: None,
575            title: "Fuite d'eau cuisine".to_string(),
576            description: "L'eau coule sous l'évier de la cuisine commune".to_string(),
577            category: TicketCategory::Plumbing,
578            priority: TicketPriority::High,
579        }
580    }
581
582    /// Helper: create a ticket through the use case and return its id
583    async fn create_ticket_helper(
584        use_cases: &TicketUseCases,
585        org_id: Uuid,
586        building_id: Uuid,
587    ) -> TicketResponse {
588        let user_id = Uuid::new_v4();
589        let request = make_create_request(building_id);
590        use_cases
591            .create_ticket(org_id, user_id, request)
592            .await
593            .expect("create_ticket should succeed")
594    }
595
596    // ── Tests ────────────────────────────────────────────────────────
597
598    #[tokio::test]
599    async fn test_create_ticket_success() {
600        let repo = Arc::new(MockTicketRepository::new());
601        let use_cases = make_use_cases(repo);
602
603        let org_id = Uuid::new_v4();
604        let building_id = Uuid::new_v4();
605        let user_id = Uuid::new_v4();
606        let request = make_create_request(building_id);
607
608        let result = use_cases.create_ticket(org_id, user_id, request).await;
609
610        assert!(result.is_ok());
611        let ticket = result.unwrap();
612        assert_eq!(ticket.status, TicketStatus::Open);
613        assert_eq!(ticket.building_id, building_id);
614        assert_eq!(ticket.organization_id, org_id);
615        assert_eq!(ticket.created_by, user_id);
616        assert_eq!(ticket.title, "Fuite d'eau cuisine");
617        assert!(ticket.assigned_to.is_none());
618        assert!(ticket.resolution_notes.is_none());
619    }
620
621    #[tokio::test]
622    async fn test_create_ticket_empty_title_fails() {
623        let repo = Arc::new(MockTicketRepository::new());
624        let use_cases = make_use_cases(repo);
625
626        let request = CreateTicketRequest {
627            building_id: Uuid::new_v4(),
628            unit_id: None,
629            title: "   ".to_string(),
630            description: "Some description".to_string(),
631            category: TicketCategory::Electrical,
632            priority: TicketPriority::Low,
633        };
634
635        let result = use_cases
636            .create_ticket(Uuid::new_v4(), Uuid::new_v4(), request)
637            .await;
638
639        assert!(result.is_err());
640        assert!(result.unwrap_err().contains("Title cannot be empty"));
641    }
642
643    #[tokio::test]
644    async fn test_assign_contractor_transitions_to_in_progress() {
645        let repo = Arc::new(MockTicketRepository::new());
646        let use_cases = make_use_cases(repo);
647
648        let org_id = Uuid::new_v4();
649        let building_id = Uuid::new_v4();
650        let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
651        assert_eq!(ticket.status, TicketStatus::Open);
652
653        let contractor_id = Uuid::new_v4();
654        let assign_request = AssignTicketRequest {
655            assigned_to: contractor_id,
656        };
657
658        let result = use_cases.assign_ticket(ticket.id, assign_request).await;
659
660        assert!(result.is_ok());
661        let updated = result.unwrap();
662        assert_eq!(updated.status, TicketStatus::InProgress);
663        assert_eq!(updated.assigned_to, Some(contractor_id));
664    }
665
666    #[tokio::test]
667    async fn test_start_work_open_to_in_progress() {
668        let repo = Arc::new(MockTicketRepository::new());
669        let use_cases = make_use_cases(repo);
670
671        let org_id = Uuid::new_v4();
672        let building_id = Uuid::new_v4();
673        let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
674
675        let result = use_cases.start_work(ticket.id).await;
676
677        assert!(result.is_ok());
678        assert_eq!(result.unwrap().status, TicketStatus::InProgress);
679    }
680
681    #[tokio::test]
682    async fn test_resolve_ticket_with_notes() {
683        let repo = Arc::new(MockTicketRepository::new());
684        let use_cases = make_use_cases(repo);
685
686        let org_id = Uuid::new_v4();
687        let building_id = Uuid::new_v4();
688        let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
689
690        // Transition to InProgress first
691        use_cases.start_work(ticket.id).await.unwrap();
692
693        let resolve_request = ResolveTicketRequest {
694            resolution_notes: "Fuite réparée, joint remplacé".to_string(),
695        };
696        let result = use_cases.resolve_ticket(ticket.id, resolve_request).await;
697
698        assert!(result.is_ok());
699        let resolved = result.unwrap();
700        assert_eq!(resolved.status, TicketStatus::Resolved);
701        assert_eq!(
702            resolved.resolution_notes.as_deref(),
703            Some("Fuite réparée, joint remplacé")
704        );
705        assert!(resolved.resolved_at.is_some());
706    }
707
708    #[tokio::test]
709    async fn test_close_ticket_after_resolution() {
710        let repo = Arc::new(MockTicketRepository::new());
711        let use_cases = make_use_cases(repo);
712
713        let org_id = Uuid::new_v4();
714        let building_id = Uuid::new_v4();
715        let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
716
717        // Full lifecycle: Open -> InProgress -> Resolved -> Closed
718        use_cases.start_work(ticket.id).await.unwrap();
719        use_cases
720            .resolve_ticket(
721                ticket.id,
722                ResolveTicketRequest {
723                    resolution_notes: "Done".to_string(),
724                },
725            )
726            .await
727            .unwrap();
728
729        let result = use_cases.close_ticket(ticket.id).await;
730
731        assert!(result.is_ok());
732        let closed = result.unwrap();
733        assert_eq!(closed.status, TicketStatus::Closed);
734        assert!(closed.closed_at.is_some());
735    }
736
737    #[tokio::test]
738    async fn test_close_open_ticket_fails() {
739        let repo = Arc::new(MockTicketRepository::new());
740        let use_cases = make_use_cases(repo);
741
742        let org_id = Uuid::new_v4();
743        let building_id = Uuid::new_v4();
744        let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
745
746        // Attempt to close directly from Open (skipping Resolved)
747        let result = use_cases.close_ticket(ticket.id).await;
748
749        assert!(result.is_err());
750        assert!(result.unwrap_err().contains("Must be Resolved first"));
751    }
752
753    #[tokio::test]
754    async fn test_cancel_ticket_with_reason() {
755        let repo = Arc::new(MockTicketRepository::new());
756        let use_cases = make_use_cases(repo);
757
758        let org_id = Uuid::new_v4();
759        let building_id = Uuid::new_v4();
760        let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
761
762        let cancel_request = CancelTicketRequest {
763            reason: "Erreur de déclaration, problème déjà résolu".to_string(),
764        };
765        let result = use_cases.cancel_ticket(ticket.id, cancel_request).await;
766
767        assert!(result.is_ok());
768        let cancelled = result.unwrap();
769        assert_eq!(cancelled.status, TicketStatus::Cancelled);
770        assert!(cancelled
771            .resolution_notes
772            .as_deref()
773            .unwrap()
774            .contains("CANCELLED"));
775    }
776
777    #[tokio::test]
778    async fn test_reopen_resolved_ticket() {
779        let repo = Arc::new(MockTicketRepository::new());
780        let use_cases = make_use_cases(repo);
781
782        let org_id = Uuid::new_v4();
783        let building_id = Uuid::new_v4();
784        let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
785
786        // Open -> InProgress -> Resolved
787        use_cases.start_work(ticket.id).await.unwrap();
788        use_cases
789            .resolve_ticket(
790                ticket.id,
791                ResolveTicketRequest {
792                    resolution_notes: "Fixed".to_string(),
793                },
794            )
795            .await
796            .unwrap();
797
798        // Reopen
799        let reopen_request = ReopenTicketRequest {
800            reason: "Problème persiste après intervention".to_string(),
801        };
802        let result = use_cases.reopen_ticket(ticket.id, reopen_request).await;
803
804        assert!(result.is_ok());
805        let reopened = result.unwrap();
806        assert_eq!(reopened.status, TicketStatus::InProgress);
807        assert!(reopened.resolved_at.is_none());
808        assert!(reopened.closed_at.is_none());
809        assert!(reopened
810            .resolution_notes
811            .as_deref()
812            .unwrap()
813            .contains("REOPENED"));
814    }
815
816    #[tokio::test]
817    async fn test_reopen_open_ticket_fails() {
818        let repo = Arc::new(MockTicketRepository::new());
819        let use_cases = make_use_cases(repo);
820
821        let org_id = Uuid::new_v4();
822        let building_id = Uuid::new_v4();
823        let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
824
825        // Ticket is Open, cannot reopen
826        let reopen_request = ReopenTicketRequest {
827            reason: "Some reason".to_string(),
828        };
829        let result = use_cases.reopen_ticket(ticket.id, reopen_request).await;
830
831        assert!(result.is_err());
832        assert!(result
833            .unwrap_err()
834            .contains("Can only reopen resolved or closed tickets"));
835    }
836
837    #[tokio::test]
838    async fn test_send_work_order_in_progress_ticket() {
839        let repo = Arc::new(MockTicketRepository::new());
840        let use_cases = make_use_cases(repo);
841
842        let org_id = Uuid::new_v4();
843        let building_id = Uuid::new_v4();
844        let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
845
846        // Assign contractor (auto-transitions to InProgress)
847        let contractor_id = Uuid::new_v4();
848        use_cases
849            .assign_ticket(
850                ticket.id,
851                AssignTicketRequest {
852                    assigned_to: contractor_id,
853                },
854            )
855            .await
856            .unwrap();
857
858        // Send work order
859        let result = use_cases.send_work_order(ticket.id).await;
860
861        assert!(result.is_ok());
862        let updated = result.unwrap();
863        assert!(updated.work_order_sent_at.is_some());
864        assert_eq!(updated.status, TicketStatus::InProgress);
865    }
866
867    #[tokio::test]
868    async fn test_send_work_order_open_ticket_fails() {
869        let repo = Arc::new(MockTicketRepository::new());
870        let use_cases = make_use_cases(repo);
871
872        let org_id = Uuid::new_v4();
873        let building_id = Uuid::new_v4();
874        let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
875
876        // Ticket is Open (not InProgress), should fail
877        let result = use_cases.send_work_order(ticket.id).await;
878
879        assert!(result.is_err());
880        assert!(result.unwrap_err().contains("InProgress"));
881    }
882
883    #[tokio::test]
884    async fn test_get_overdue_tickets() {
885        let repo = Arc::new(MockTicketRepository::new());
886        let use_cases = make_use_cases(repo.clone());
887
888        let org_id = Uuid::new_v4();
889        let building_id = Uuid::new_v4();
890        let user_id = Uuid::new_v4();
891
892        // Create a ticket and manually age it
893        let request = make_create_request(building_id);
894        let created = use_cases
895            .create_ticket(org_id, user_id, request)
896            .await
897            .unwrap();
898
899        // Manually set created_at to 10 days ago in the mock store
900        {
901            let mut store = repo.tickets.lock().unwrap();
902            if let Some(ticket) = store.get_mut(&created.id) {
903                ticket.created_at = chrono::Utc::now() - chrono::Duration::days(10);
904            }
905        }
906
907        let overdue = use_cases.get_overdue_tickets(building_id, 5).await.unwrap();
908        assert_eq!(overdue.len(), 1);
909        assert_eq!(overdue[0].id, created.id);
910
911        // With a longer threshold, ticket should not be overdue
912        let not_overdue = use_cases
913            .get_overdue_tickets(building_id, 15)
914            .await
915            .unwrap();
916        assert_eq!(not_overdue.len(), 0);
917    }
918
919    #[tokio::test]
920    async fn test_get_ticket_statistics() {
921        let repo = Arc::new(MockTicketRepository::new());
922        let use_cases = make_use_cases(repo);
923
924        let org_id = Uuid::new_v4();
925        let building_id = Uuid::new_v4();
926
927        // Create 3 tickets, transition them to different states
928        let _t1 = create_ticket_helper(&use_cases, org_id, building_id).await; // stays Open
929        let t2 = create_ticket_helper(&use_cases, org_id, building_id).await;
930        let t3 = create_ticket_helper(&use_cases, org_id, building_id).await;
931
932        // t2 -> InProgress -> Resolved
933        use_cases.start_work(t2.id).await.unwrap();
934        use_cases
935            .resolve_ticket(
936                t2.id,
937                ResolveTicketRequest {
938                    resolution_notes: "Done".to_string(),
939                },
940            )
941            .await
942            .unwrap();
943
944        // t3 -> Cancelled
945        use_cases
946            .cancel_ticket(
947                t3.id,
948                CancelTicketRequest {
949                    reason: "Duplicate".to_string(),
950                },
951            )
952            .await
953            .unwrap();
954
955        let stats = use_cases.get_ticket_statistics(building_id).await.unwrap();
956
957        assert_eq!(stats.total, 3);
958        assert_eq!(stats.open, 1); // t1
959        assert_eq!(stats.in_progress, 0);
960        assert_eq!(stats.resolved, 1); // t2
961        assert_eq!(stats.closed, 0);
962        assert_eq!(stats.cancelled, 1); // t3
963    }
964
965    #[tokio::test]
966    async fn test_delete_open_ticket_succeeds() {
967        let repo = Arc::new(MockTicketRepository::new());
968        let use_cases = make_use_cases(repo);
969
970        let org_id = Uuid::new_v4();
971        let building_id = Uuid::new_v4();
972        let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
973
974        let result = use_cases.delete_ticket(ticket.id).await;
975
976        assert!(result.is_ok());
977        assert!(result.unwrap());
978
979        // Verify ticket no longer exists
980        let fetched = use_cases.get_ticket(ticket.id).await.unwrap();
981        assert!(fetched.is_none());
982    }
983
984    #[tokio::test]
985    async fn test_delete_in_progress_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        // Transition to InProgress
994        use_cases.start_work(ticket.id).await.unwrap();
995
996        let result = use_cases.delete_ticket(ticket.id).await;
997
998        assert!(result.is_err());
999        assert!(result
1000            .unwrap_err()
1001            .contains("Can only delete tickets with Open status"));
1002    }
1003}