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        let in_progress = self
236            .ticket_repository
237            .count_by_status(building_id, TicketStatus::InProgress)
238            .await?;
239
240        let resolved = self
241            .ticket_repository
242            .count_by_status(building_id, TicketStatus::Resolved)
243            .await?;
244
245        let closed = self
246            .ticket_repository
247            .count_by_status(building_id, TicketStatus::Closed)
248            .await?;
249
250        let cancelled = self
251            .ticket_repository
252            .count_by_status(building_id, TicketStatus::Cancelled)
253            .await?;
254
255        Ok(TicketStatistics {
256            total,
257            open,
258            in_progress,
259            resolved,
260            closed,
261            cancelled,
262        })
263    }
264
265    /// Check for overdue tickets (open for more than specified days)
266    pub async fn get_overdue_tickets(
267        &self,
268        building_id: Uuid,
269        max_days: i64,
270    ) -> Result<Vec<TicketResponse>, String> {
271        let tickets = self.ticket_repository.find_by_building(building_id).await?;
272
273        let overdue: Vec<TicketResponse> = tickets
274            .into_iter()
275            .filter(|ticket| ticket.is_overdue(max_days))
276            .map(TicketResponse::from)
277            .collect();
278
279        Ok(overdue)
280    }
281
282    /// Get ticket statistics for an organization (all buildings)
283    pub async fn get_ticket_statistics_by_organization(
284        &self,
285        organization_id: Uuid,
286    ) -> Result<TicketStatistics, String> {
287        let total = self
288            .ticket_repository
289            .count_by_organization(organization_id)
290            .await?;
291
292        let open = self
293            .ticket_repository
294            .count_by_organization_and_status(organization_id, TicketStatus::Open)
295            .await?;
296
297        let in_progress = self
298            .ticket_repository
299            .count_by_organization_and_status(organization_id, TicketStatus::InProgress)
300            .await?;
301
302        let resolved = self
303            .ticket_repository
304            .count_by_organization_and_status(organization_id, TicketStatus::Resolved)
305            .await?;
306
307        let closed = self
308            .ticket_repository
309            .count_by_organization_and_status(organization_id, TicketStatus::Closed)
310            .await?;
311
312        let cancelled = self
313            .ticket_repository
314            .count_by_organization_and_status(organization_id, TicketStatus::Cancelled)
315            .await?;
316
317        Ok(TicketStatistics {
318            total,
319            open,
320            in_progress,
321            resolved,
322            closed,
323            cancelled,
324        })
325    }
326
327    /// Check for overdue tickets in organization (all buildings)
328    pub async fn get_overdue_tickets_by_organization(
329        &self,
330        organization_id: Uuid,
331        max_days: i64,
332    ) -> Result<Vec<TicketResponse>, String> {
333        let tickets = self
334            .ticket_repository
335            .find_by_organization(organization_id)
336            .await?;
337
338        let overdue: Vec<TicketResponse> = tickets
339            .into_iter()
340            .filter(|ticket| ticket.is_overdue(max_days))
341            .map(TicketResponse::from)
342            .collect();
343
344        Ok(overdue)
345    }
346
347    /// Send work order to contractor via PWA magic link (Issue #309)
348    /// Validates ticket is in InProgress status and sends work order notification
349    pub async fn send_work_order(&self, ticket_id: Uuid) -> Result<TicketResponse, String> {
350        let mut ticket = self
351            .ticket_repository
352            .find_by_id(ticket_id)
353            .await?
354            .ok_or_else(|| "Ticket not found".to_string())?;
355
356        ticket.send_work_order_to_contractor()?;
357        let updated = self.ticket_repository.update(&ticket).await?;
358
359        Ok(TicketResponse::from(updated))
360    }
361}
362
363/// Ticket statistics for a building
364#[derive(Debug, serde::Serialize)]
365pub struct TicketStatistics {
366    pub total: i64,
367    pub open: i64,
368    pub in_progress: i64,
369    pub resolved: i64,
370    pub closed: i64,
371    pub cancelled: i64,
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use crate::application::ports::TicketRepository;
378    use crate::domain::entities::{Ticket, TicketCategory, TicketPriority, TicketStatus};
379    use async_trait::async_trait;
380    use std::collections::HashMap;
381    use std::sync::Mutex;
382
383    // ── Manual mock for TicketRepository ──────────────────────────────
384
385    struct MockTicketRepository {
386        tickets: Mutex<HashMap<Uuid, Ticket>>,
387    }
388
389    impl MockTicketRepository {
390        fn new() -> Self {
391            Self {
392                tickets: Mutex::new(HashMap::new()),
393            }
394        }
395    }
396
397    #[async_trait]
398    impl TicketRepository for MockTicketRepository {
399        async fn create(&self, ticket: &Ticket) -> Result<Ticket, String> {
400            self.tickets
401                .lock()
402                .unwrap()
403                .insert(ticket.id, ticket.clone());
404            Ok(ticket.clone())
405        }
406
407        async fn find_by_id(&self, id: Uuid) -> Result<Option<Ticket>, String> {
408            Ok(self.tickets.lock().unwrap().get(&id).cloned())
409        }
410
411        async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Ticket>, String> {
412            Ok(self
413                .tickets
414                .lock()
415                .unwrap()
416                .values()
417                .filter(|t| t.building_id == building_id)
418                .cloned()
419                .collect())
420        }
421
422        async fn find_by_organization(&self, organization_id: Uuid) -> Result<Vec<Ticket>, String> {
423            Ok(self
424                .tickets
425                .lock()
426                .unwrap()
427                .values()
428                .filter(|t| t.organization_id == organization_id)
429                .cloned()
430                .collect())
431        }
432
433        async fn find_by_created_by(&self, created_by: Uuid) -> Result<Vec<Ticket>, String> {
434            Ok(self
435                .tickets
436                .lock()
437                .unwrap()
438                .values()
439                .filter(|t| t.created_by == created_by)
440                .cloned()
441                .collect())
442        }
443
444        async fn find_by_assigned_to(&self, assigned_to: Uuid) -> Result<Vec<Ticket>, String> {
445            Ok(self
446                .tickets
447                .lock()
448                .unwrap()
449                .values()
450                .filter(|t| t.assigned_to == Some(assigned_to))
451                .cloned()
452                .collect())
453        }
454
455        async fn find_by_status(
456            &self,
457            building_id: Uuid,
458            status: TicketStatus,
459        ) -> Result<Vec<Ticket>, String> {
460            Ok(self
461                .tickets
462                .lock()
463                .unwrap()
464                .values()
465                .filter(|t| t.building_id == building_id && t.status == status)
466                .cloned()
467                .collect())
468        }
469
470        async fn update(&self, ticket: &Ticket) -> Result<Ticket, String> {
471            self.tickets
472                .lock()
473                .unwrap()
474                .insert(ticket.id, ticket.clone());
475            Ok(ticket.clone())
476        }
477
478        async fn delete(&self, id: Uuid) -> Result<bool, String> {
479            Ok(self.tickets.lock().unwrap().remove(&id).is_some())
480        }
481
482        async fn count_by_building(&self, building_id: Uuid) -> Result<i64, String> {
483            Ok(self
484                .tickets
485                .lock()
486                .unwrap()
487                .values()
488                .filter(|t| t.building_id == building_id)
489                .count() as i64)
490        }
491
492        async fn count_by_status(
493            &self,
494            building_id: Uuid,
495            status: TicketStatus,
496        ) -> Result<i64, String> {
497            Ok(self
498                .tickets
499                .lock()
500                .unwrap()
501                .values()
502                .filter(|t| t.building_id == building_id && t.status == status)
503                .count() as i64)
504        }
505
506        async fn count_by_organization(&self, organization_id: Uuid) -> Result<i64, String> {
507            Ok(self
508                .tickets
509                .lock()
510                .unwrap()
511                .values()
512                .filter(|t| t.organization_id == organization_id)
513                .count() as i64)
514        }
515
516        async fn count_by_organization_and_status(
517            &self,
518            organization_id: Uuid,
519            status: TicketStatus,
520        ) -> Result<i64, String> {
521            Ok(self
522                .tickets
523                .lock()
524                .unwrap()
525                .values()
526                .filter(|t| t.organization_id == organization_id && t.status == status)
527                .count() as i64)
528        }
529    }
530
531    // ── Helpers ──────────────────────────────────────────────────────
532
533    fn make_use_cases(repo: Arc<dyn TicketRepository>) -> TicketUseCases {
534        TicketUseCases::new(repo)
535    }
536
537    fn make_create_request(building_id: Uuid) -> CreateTicketRequest {
538        CreateTicketRequest {
539            building_id,
540            unit_id: None,
541            title: "Fuite d'eau cuisine".to_string(),
542            description: "L'eau coule sous l'évier de la cuisine commune".to_string(),
543            category: TicketCategory::Plumbing,
544            priority: TicketPriority::High,
545        }
546    }
547
548    /// Helper: create a ticket through the use case and return its id
549    async fn create_ticket_helper(
550        use_cases: &TicketUseCases,
551        org_id: Uuid,
552        building_id: Uuid,
553    ) -> TicketResponse {
554        let user_id = Uuid::new_v4();
555        let request = make_create_request(building_id);
556        use_cases
557            .create_ticket(org_id, user_id, request)
558            .await
559            .expect("create_ticket should succeed")
560    }
561
562    // ── Tests ────────────────────────────────────────────────────────
563
564    #[tokio::test]
565    async fn test_create_ticket_success() {
566        let repo = Arc::new(MockTicketRepository::new());
567        let use_cases = make_use_cases(repo);
568
569        let org_id = Uuid::new_v4();
570        let building_id = Uuid::new_v4();
571        let user_id = Uuid::new_v4();
572        let request = make_create_request(building_id);
573
574        let result = use_cases.create_ticket(org_id, user_id, request).await;
575
576        assert!(result.is_ok());
577        let ticket = result.unwrap();
578        assert_eq!(ticket.status, TicketStatus::Open);
579        assert_eq!(ticket.building_id, building_id);
580        assert_eq!(ticket.organization_id, org_id);
581        assert_eq!(ticket.created_by, user_id);
582        assert_eq!(ticket.title, "Fuite d'eau cuisine");
583        assert!(ticket.assigned_to.is_none());
584        assert!(ticket.resolution_notes.is_none());
585    }
586
587    #[tokio::test]
588    async fn test_create_ticket_empty_title_fails() {
589        let repo = Arc::new(MockTicketRepository::new());
590        let use_cases = make_use_cases(repo);
591
592        let request = CreateTicketRequest {
593            building_id: Uuid::new_v4(),
594            unit_id: None,
595            title: "   ".to_string(),
596            description: "Some description".to_string(),
597            category: TicketCategory::Electrical,
598            priority: TicketPriority::Low,
599        };
600
601        let result = use_cases
602            .create_ticket(Uuid::new_v4(), Uuid::new_v4(), request)
603            .await;
604
605        assert!(result.is_err());
606        assert!(result.unwrap_err().contains("Title cannot be empty"));
607    }
608
609    #[tokio::test]
610    async fn test_assign_contractor_transitions_to_in_progress() {
611        let repo = Arc::new(MockTicketRepository::new());
612        let use_cases = make_use_cases(repo);
613
614        let org_id = Uuid::new_v4();
615        let building_id = Uuid::new_v4();
616        let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
617        assert_eq!(ticket.status, TicketStatus::Open);
618
619        let contractor_id = Uuid::new_v4();
620        let assign_request = AssignTicketRequest {
621            assigned_to: contractor_id,
622        };
623
624        let result = use_cases.assign_ticket(ticket.id, assign_request).await;
625
626        assert!(result.is_ok());
627        let updated = result.unwrap();
628        assert_eq!(updated.status, TicketStatus::InProgress);
629        assert_eq!(updated.assigned_to, Some(contractor_id));
630    }
631
632    #[tokio::test]
633    async fn test_start_work_open_to_in_progress() {
634        let repo = Arc::new(MockTicketRepository::new());
635        let use_cases = make_use_cases(repo);
636
637        let org_id = Uuid::new_v4();
638        let building_id = Uuid::new_v4();
639        let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
640
641        let result = use_cases.start_work(ticket.id).await;
642
643        assert!(result.is_ok());
644        assert_eq!(result.unwrap().status, TicketStatus::InProgress);
645    }
646
647    #[tokio::test]
648    async fn test_resolve_ticket_with_notes() {
649        let repo = Arc::new(MockTicketRepository::new());
650        let use_cases = make_use_cases(repo);
651
652        let org_id = Uuid::new_v4();
653        let building_id = Uuid::new_v4();
654        let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
655
656        // Transition to InProgress first
657        use_cases.start_work(ticket.id).await.unwrap();
658
659        let resolve_request = ResolveTicketRequest {
660            resolution_notes: "Fuite réparée, joint remplacé".to_string(),
661        };
662        let result = use_cases.resolve_ticket(ticket.id, resolve_request).await;
663
664        assert!(result.is_ok());
665        let resolved = result.unwrap();
666        assert_eq!(resolved.status, TicketStatus::Resolved);
667        assert_eq!(
668            resolved.resolution_notes.as_deref(),
669            Some("Fuite réparée, joint remplacé")
670        );
671        assert!(resolved.resolved_at.is_some());
672    }
673
674    #[tokio::test]
675    async fn test_close_ticket_after_resolution() {
676        let repo = Arc::new(MockTicketRepository::new());
677        let use_cases = make_use_cases(repo);
678
679        let org_id = Uuid::new_v4();
680        let building_id = Uuid::new_v4();
681        let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
682
683        // Full lifecycle: Open -> InProgress -> Resolved -> Closed
684        use_cases.start_work(ticket.id).await.unwrap();
685        use_cases
686            .resolve_ticket(
687                ticket.id,
688                ResolveTicketRequest {
689                    resolution_notes: "Done".to_string(),
690                },
691            )
692            .await
693            .unwrap();
694
695        let result = use_cases.close_ticket(ticket.id).await;
696
697        assert!(result.is_ok());
698        let closed = result.unwrap();
699        assert_eq!(closed.status, TicketStatus::Closed);
700        assert!(closed.closed_at.is_some());
701    }
702
703    #[tokio::test]
704    async fn test_close_open_ticket_fails() {
705        let repo = Arc::new(MockTicketRepository::new());
706        let use_cases = make_use_cases(repo);
707
708        let org_id = Uuid::new_v4();
709        let building_id = Uuid::new_v4();
710        let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
711
712        // Attempt to close directly from Open (skipping Resolved)
713        let result = use_cases.close_ticket(ticket.id).await;
714
715        assert!(result.is_err());
716        assert!(result.unwrap_err().contains("Must be Resolved first"));
717    }
718
719    #[tokio::test]
720    async fn test_cancel_ticket_with_reason() {
721        let repo = Arc::new(MockTicketRepository::new());
722        let use_cases = make_use_cases(repo);
723
724        let org_id = Uuid::new_v4();
725        let building_id = Uuid::new_v4();
726        let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
727
728        let cancel_request = CancelTicketRequest {
729            reason: "Erreur de déclaration, problème déjà résolu".to_string(),
730        };
731        let result = use_cases.cancel_ticket(ticket.id, cancel_request).await;
732
733        assert!(result.is_ok());
734        let cancelled = result.unwrap();
735        assert_eq!(cancelled.status, TicketStatus::Cancelled);
736        assert!(cancelled
737            .resolution_notes
738            .as_deref()
739            .unwrap()
740            .contains("CANCELLED"));
741    }
742
743    #[tokio::test]
744    async fn test_reopen_resolved_ticket() {
745        let repo = Arc::new(MockTicketRepository::new());
746        let use_cases = make_use_cases(repo);
747
748        let org_id = Uuid::new_v4();
749        let building_id = Uuid::new_v4();
750        let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
751
752        // Open -> InProgress -> Resolved
753        use_cases.start_work(ticket.id).await.unwrap();
754        use_cases
755            .resolve_ticket(
756                ticket.id,
757                ResolveTicketRequest {
758                    resolution_notes: "Fixed".to_string(),
759                },
760            )
761            .await
762            .unwrap();
763
764        // Reopen
765        let reopen_request = ReopenTicketRequest {
766            reason: "Problème persiste après intervention".to_string(),
767        };
768        let result = use_cases.reopen_ticket(ticket.id, reopen_request).await;
769
770        assert!(result.is_ok());
771        let reopened = result.unwrap();
772        assert_eq!(reopened.status, TicketStatus::InProgress);
773        assert!(reopened.resolved_at.is_none());
774        assert!(reopened.closed_at.is_none());
775        assert!(reopened
776            .resolution_notes
777            .as_deref()
778            .unwrap()
779            .contains("REOPENED"));
780    }
781
782    #[tokio::test]
783    async fn test_reopen_open_ticket_fails() {
784        let repo = Arc::new(MockTicketRepository::new());
785        let use_cases = make_use_cases(repo);
786
787        let org_id = Uuid::new_v4();
788        let building_id = Uuid::new_v4();
789        let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
790
791        // Ticket is Open, cannot reopen
792        let reopen_request = ReopenTicketRequest {
793            reason: "Some reason".to_string(),
794        };
795        let result = use_cases.reopen_ticket(ticket.id, reopen_request).await;
796
797        assert!(result.is_err());
798        assert!(result
799            .unwrap_err()
800            .contains("Can only reopen resolved or closed tickets"));
801    }
802
803    #[tokio::test]
804    async fn test_send_work_order_in_progress_ticket() {
805        let repo = Arc::new(MockTicketRepository::new());
806        let use_cases = make_use_cases(repo);
807
808        let org_id = Uuid::new_v4();
809        let building_id = Uuid::new_v4();
810        let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
811
812        // Assign contractor (auto-transitions to InProgress)
813        let contractor_id = Uuid::new_v4();
814        use_cases
815            .assign_ticket(
816                ticket.id,
817                AssignTicketRequest {
818                    assigned_to: contractor_id,
819                },
820            )
821            .await
822            .unwrap();
823
824        // Send work order
825        let result = use_cases.send_work_order(ticket.id).await;
826
827        assert!(result.is_ok());
828        let updated = result.unwrap();
829        assert!(updated.work_order_sent_at.is_some());
830        assert_eq!(updated.status, TicketStatus::InProgress);
831    }
832
833    #[tokio::test]
834    async fn test_send_work_order_open_ticket_fails() {
835        let repo = Arc::new(MockTicketRepository::new());
836        let use_cases = make_use_cases(repo);
837
838        let org_id = Uuid::new_v4();
839        let building_id = Uuid::new_v4();
840        let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
841
842        // Ticket is Open (not InProgress), should fail
843        let result = use_cases.send_work_order(ticket.id).await;
844
845        assert!(result.is_err());
846        assert!(result.unwrap_err().contains("InProgress"));
847    }
848
849    #[tokio::test]
850    async fn test_get_overdue_tickets() {
851        let repo = Arc::new(MockTicketRepository::new());
852        let use_cases = make_use_cases(repo.clone());
853
854        let org_id = Uuid::new_v4();
855        let building_id = Uuid::new_v4();
856        let user_id = Uuid::new_v4();
857
858        // Create a ticket and manually age it
859        let request = make_create_request(building_id);
860        let created = use_cases
861            .create_ticket(org_id, user_id, request)
862            .await
863            .unwrap();
864
865        // Manually set created_at to 10 days ago in the mock store
866        {
867            let mut store = repo.tickets.lock().unwrap();
868            if let Some(ticket) = store.get_mut(&created.id) {
869                ticket.created_at = chrono::Utc::now() - chrono::Duration::days(10);
870            }
871        }
872
873        let overdue = use_cases.get_overdue_tickets(building_id, 5).await.unwrap();
874        assert_eq!(overdue.len(), 1);
875        assert_eq!(overdue[0].id, created.id);
876
877        // With a longer threshold, ticket should not be overdue
878        let not_overdue = use_cases
879            .get_overdue_tickets(building_id, 15)
880            .await
881            .unwrap();
882        assert_eq!(not_overdue.len(), 0);
883    }
884
885    #[tokio::test]
886    async fn test_get_ticket_statistics() {
887        let repo = Arc::new(MockTicketRepository::new());
888        let use_cases = make_use_cases(repo);
889
890        let org_id = Uuid::new_v4();
891        let building_id = Uuid::new_v4();
892
893        // Create 3 tickets, transition them to different states
894        let t1 = create_ticket_helper(&use_cases, org_id, building_id).await; // stays Open
895        let t2 = create_ticket_helper(&use_cases, org_id, building_id).await;
896        let t3 = create_ticket_helper(&use_cases, org_id, building_id).await;
897
898        // t2 -> InProgress -> Resolved
899        use_cases.start_work(t2.id).await.unwrap();
900        use_cases
901            .resolve_ticket(
902                t2.id,
903                ResolveTicketRequest {
904                    resolution_notes: "Done".to_string(),
905                },
906            )
907            .await
908            .unwrap();
909
910        // t3 -> Cancelled
911        use_cases
912            .cancel_ticket(
913                t3.id,
914                CancelTicketRequest {
915                    reason: "Duplicate".to_string(),
916                },
917            )
918            .await
919            .unwrap();
920
921        let stats = use_cases.get_ticket_statistics(building_id).await.unwrap();
922
923        assert_eq!(stats.total, 3);
924        assert_eq!(stats.open, 1); // t1
925        assert_eq!(stats.in_progress, 0);
926        assert_eq!(stats.resolved, 1); // t2
927        assert_eq!(stats.closed, 0);
928        assert_eq!(stats.cancelled, 1); // t3
929    }
930
931    #[tokio::test]
932    async fn test_delete_open_ticket_succeeds() {
933        let repo = Arc::new(MockTicketRepository::new());
934        let use_cases = make_use_cases(repo);
935
936        let org_id = Uuid::new_v4();
937        let building_id = Uuid::new_v4();
938        let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
939
940        let result = use_cases.delete_ticket(ticket.id).await;
941
942        assert!(result.is_ok());
943        assert!(result.unwrap());
944
945        // Verify ticket no longer exists
946        let fetched = use_cases.get_ticket(ticket.id).await.unwrap();
947        assert!(fetched.is_none());
948    }
949
950    #[tokio::test]
951    async fn test_delete_in_progress_ticket_fails() {
952        let repo = Arc::new(MockTicketRepository::new());
953        let use_cases = make_use_cases(repo);
954
955        let org_id = Uuid::new_v4();
956        let building_id = Uuid::new_v4();
957        let ticket = create_ticket_helper(&use_cases, org_id, building_id).await;
958
959        // Transition to InProgress
960        use_cases.start_work(ticket.id).await.unwrap();
961
962        let result = use_cases.delete_ticket(ticket.id).await;
963
964        assert!(result.is_err());
965        assert!(result
966            .unwrap_err()
967            .contains("Can only delete tickets with Open status"));
968    }
969}