koprogo_api/application/use_cases/
age_request_use_cases.rs

1use crate::application::dto::age_request_dto::{
2    AddCosignatoryDto, AgeRequestResponseDto, CreateAgeRequestDto, SyndicResponseDto,
3};
4use crate::application::ports::age_request_repository::AgeRequestRepository;
5use crate::domain::entities::age_request::{AgeRequest, AgeRequestCosignatory};
6use std::sync::Arc;
7use uuid::Uuid;
8
9pub struct AgeRequestUseCases {
10    pub repo: Arc<dyn AgeRequestRepository>,
11}
12
13impl AgeRequestUseCases {
14    pub fn new(repo: Arc<dyn AgeRequestRepository>) -> Self {
15        Self { repo }
16    }
17
18    /// Crée une nouvelle demande d'AGE (B17-1)
19    pub async fn create(
20        &self,
21        organization_id: Uuid,
22        created_by: Uuid,
23        dto: CreateAgeRequestDto,
24    ) -> Result<AgeRequestResponseDto, String> {
25        let age_request = AgeRequest::new(
26            organization_id,
27            dto.building_id,
28            dto.title,
29            dto.description,
30            created_by,
31        )?;
32        let saved = self.repo.create(&age_request).await?;
33        Ok(AgeRequestResponseDto::from(&saved))
34    }
35
36    /// Récupère une demande par son ID
37    pub async fn get(
38        &self,
39        id: Uuid,
40        organization_id: Uuid,
41    ) -> Result<AgeRequestResponseDto, String> {
42        let req = self
43            .repo
44            .find_by_id(id)
45            .await?
46            .ok_or_else(|| format!("Demande AGE {} introuvable", id))?;
47
48        if req.organization_id != organization_id {
49            return Err("Accès refusé".to_string());
50        }
51
52        Ok(AgeRequestResponseDto::from(&req))
53    }
54
55    /// Liste les demandes d'un bâtiment
56    pub async fn list_by_building(
57        &self,
58        building_id: Uuid,
59        organization_id: Uuid,
60    ) -> Result<Vec<AgeRequestResponseDto>, String> {
61        let requests = self.repo.find_by_building(building_id).await?;
62        // Filtrer par organisation (sécurité)
63        let filtered: Vec<_> = requests
64            .iter()
65            .filter(|r| r.organization_id == organization_id)
66            .map(AgeRequestResponseDto::from)
67            .collect();
68        Ok(filtered)
69    }
70
71    /// Ouvre une demande pour signatures publiques (Draft → Open)
72    pub async fn open(
73        &self,
74        id: Uuid,
75        organization_id: Uuid,
76        requester_id: Uuid,
77    ) -> Result<AgeRequestResponseDto, String> {
78        let mut req = self
79            .repo
80            .find_by_id(id)
81            .await?
82            .ok_or_else(|| format!("Demande AGE {} introuvable", id))?;
83
84        if req.organization_id != organization_id {
85            return Err("Accès refusé".to_string());
86        }
87        if req.created_by != requester_id {
88            return Err("Seul l'initiateur peut ouvrir cette demande".to_string());
89        }
90
91        req.open()?;
92        let updated = self.repo.update(&req).await?;
93        Ok(AgeRequestResponseDto::from(&updated))
94    }
95
96    /// Ajoute un cosignataire à la demande (B17-2)
97    /// Calcule automatiquement si le seuil 1/5 est atteint
98    pub async fn add_cosignatory(
99        &self,
100        id: Uuid,
101        organization_id: Uuid,
102        dto: AddCosignatoryDto,
103    ) -> Result<AgeRequestResponseDto, String> {
104        let mut req = self
105            .repo
106            .find_by_id(id)
107            .await?
108            .ok_or_else(|| format!("Demande AGE {} introuvable", id))?;
109
110        if req.organization_id != organization_id {
111            return Err("Accès refusé".to_string());
112        }
113
114        let _newly_reached = req.add_cosignatory(dto.owner_id, dto.shares_pct)?;
115
116        // Persister la mise à jour du total + status
117        let updated = self.repo.update(&req).await?;
118
119        // Persister le cosignataire en BDD
120        let cosignatory = AgeRequestCosignatory::new(id, dto.owner_id, dto.shares_pct)?;
121        self.repo.add_cosignatory(&cosignatory).await?;
122
123        // Recharger avec tous les cosignataires (pour le DTO complet)
124        let full = self.repo.find_by_id(id).await?.unwrap_or(updated);
125
126        Ok(AgeRequestResponseDto::from(&full))
127    }
128
129    /// Retire un cosignataire
130    pub async fn remove_cosignatory(
131        &self,
132        id: Uuid,
133        owner_id: Uuid,
134        organization_id: Uuid,
135    ) -> Result<AgeRequestResponseDto, String> {
136        let mut req = self
137            .repo
138            .find_by_id(id)
139            .await?
140            .ok_or_else(|| format!("Demande AGE {} introuvable", id))?;
141
142        if req.organization_id != organization_id {
143            return Err("Accès refusé".to_string());
144        }
145
146        req.remove_cosignatory(owner_id)?;
147
148        // Supprimer de la BDD
149        self.repo.remove_cosignatory(id, owner_id).await?;
150
151        // Persister les changements de status / total
152        let updated = self.repo.update(&req).await?;
153        let full = self.repo.find_by_id(id).await?.unwrap_or(updated);
154        Ok(AgeRequestResponseDto::from(&full))
155    }
156
157    /// Soumet la demande au syndic (Reached → Submitted) - B17-3
158    /// Démarre le délai de 15j (Art. 3.87 §2 CC)
159    pub async fn submit_to_syndic(
160        &self,
161        id: Uuid,
162        organization_id: Uuid,
163        requester_id: Uuid,
164    ) -> Result<AgeRequestResponseDto, String> {
165        let mut req = self
166            .repo
167            .find_by_id(id)
168            .await?
169            .ok_or_else(|| format!("Demande AGE {} introuvable", id))?;
170
171        if req.organization_id != organization_id {
172            return Err("Accès refusé".to_string());
173        }
174        if req.created_by != requester_id {
175            return Err("Seul l'initiateur peut soumettre cette demande au syndic".to_string());
176        }
177
178        req.submit_to_syndic()?;
179        let updated = self.repo.update(&req).await?;
180        Ok(AgeRequestResponseDto::from(&updated))
181    }
182
183    /// Le syndic répond à la demande (accept ou reject) - B17-3
184    pub async fn syndic_response(
185        &self,
186        id: Uuid,
187        organization_id: Uuid,
188        dto: SyndicResponseDto,
189    ) -> Result<AgeRequestResponseDto, String> {
190        let mut req = self
191            .repo
192            .find_by_id(id)
193            .await?
194            .ok_or_else(|| format!("Demande AGE {} introuvable", id))?;
195
196        if req.organization_id != organization_id {
197            return Err("Accès refusé".to_string());
198        }
199
200        if dto.accepted {
201            req.accept_by_syndic(dto.notes)?;
202        } else {
203            let reason = dto
204                .notes
205                .ok_or_else(|| "Un motif de refus est obligatoire".to_string())?;
206            req.reject_by_syndic(reason)?;
207        }
208
209        let updated = self.repo.update(&req).await?;
210        Ok(AgeRequestResponseDto::from(&updated))
211    }
212
213    /// Déclenche l'auto-convocation si le délai syndic est dépassé (B17-3)
214    /// Peut être appelé manuellement ou par un job de fond
215    pub async fn trigger_auto_convocation(
216        &self,
217        id: Uuid,
218        organization_id: Uuid,
219    ) -> Result<AgeRequestResponseDto, String> {
220        let mut req = self
221            .repo
222            .find_by_id(id)
223            .await?
224            .ok_or_else(|| format!("Demande AGE {} introuvable", id))?;
225
226        if req.organization_id != organization_id {
227            return Err("Accès refusé".to_string());
228        }
229
230        req.trigger_auto_convocation()?;
231        let updated = self.repo.update(&req).await?;
232        Ok(AgeRequestResponseDto::from(&updated))
233    }
234
235    /// Retire la demande
236    pub async fn withdraw(
237        &self,
238        id: Uuid,
239        organization_id: Uuid,
240        requester_id: Uuid,
241    ) -> Result<AgeRequestResponseDto, String> {
242        let mut req = self
243            .repo
244            .find_by_id(id)
245            .await?
246            .ok_or_else(|| format!("Demande AGE {} introuvable", id))?;
247
248        if req.organization_id != organization_id {
249            return Err("Accès refusé".to_string());
250        }
251
252        req.withdraw(requester_id)?;
253        let updated = self.repo.update(&req).await?;
254        Ok(AgeRequestResponseDto::from(&updated))
255    }
256
257    /// Job de fond : vérifie et expire les demandes dont le délai syndic est dépassé (B17-3)
258    pub async fn process_expired_deadlines(&self) -> Result<usize, String> {
259        let expired = self.repo.find_expired_deadlines().await?;
260        let count = expired.len();
261
262        for mut req in expired {
263            if let Err(e) = req.trigger_auto_convocation() {
264                // Logguer mais continuer
265                eprintln!(
266                    "Erreur trigger_auto_convocation pour demande {}: {}",
267                    req.id, e
268                );
269                continue;
270            }
271            if let Err(e) = self.repo.update(&req).await {
272                eprintln!(
273                    "Erreur update demande {} lors de l'auto-convocation: {}",
274                    req.id, e
275                );
276            }
277        }
278
279        Ok(count)
280    }
281
282    /// Supprime une demande (Draft/Withdrawn seulement)
283    pub async fn delete(
284        &self,
285        id: Uuid,
286        organization_id: Uuid,
287        requester_id: Uuid,
288    ) -> Result<(), String> {
289        let req = self
290            .repo
291            .find_by_id(id)
292            .await?
293            .ok_or_else(|| format!("Demande AGE {} introuvable", id))?;
294
295        if req.organization_id != organization_id {
296            return Err("Accès refusé".to_string());
297        }
298        if req.created_by != requester_id {
299            return Err("Seul l'initiateur peut supprimer cette demande".to_string());
300        }
301
302        use crate::domain::entities::age_request::AgeRequestStatus;
303        if req.status != AgeRequestStatus::Draft && req.status != AgeRequestStatus::Withdrawn {
304            return Err(format!(
305                "Impossible de supprimer une demande en statut {:?}. \
306                 Seules les demandes Draft ou Withdrawn peuvent être supprimées.",
307                req.status
308            ));
309        }
310
311        self.repo.delete(id).await?;
312        Ok(())
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use crate::application::dto::age_request_dto::{
320        AddCosignatoryDto, CreateAgeRequestDto, SyndicResponseDto,
321    };
322    use crate::application::ports::age_request_repository::AgeRequestRepository;
323    use crate::domain::entities::age_request::{
324        AgeRequest, AgeRequestCosignatory, AgeRequestStatus,
325    };
326    use async_trait::async_trait;
327    use chrono::{Duration, Utc};
328    use std::collections::HashMap;
329    use std::sync::Mutex;
330    use uuid::Uuid;
331
332    // ---------------------------------------------------------------------------
333    // Mock repository using Mutex<HashMap> (same pattern as resolution_use_cases)
334    // ---------------------------------------------------------------------------
335
336    struct MockAgeRequestRepository {
337        requests: Mutex<HashMap<Uuid, AgeRequest>>,
338        cosignatories: Mutex<HashMap<Uuid, Vec<AgeRequestCosignatory>>>,
339    }
340
341    impl MockAgeRequestRepository {
342        fn new() -> Self {
343            Self {
344                requests: Mutex::new(HashMap::new()),
345                cosignatories: Mutex::new(HashMap::new()),
346            }
347        }
348
349        /// Seed a pre-built AgeRequest into the mock store (for tests that need
350        /// a request in a specific state before calling use-case methods).
351        fn seed(&self, req: AgeRequest) {
352            self.requests.lock().unwrap().insert(req.id, req);
353        }
354    }
355
356    #[async_trait]
357    impl AgeRequestRepository for MockAgeRequestRepository {
358        async fn create(&self, age_request: &AgeRequest) -> Result<AgeRequest, String> {
359            self.requests
360                .lock()
361                .unwrap()
362                .insert(age_request.id, age_request.clone());
363            Ok(age_request.clone())
364        }
365
366        async fn find_by_id(&self, id: Uuid) -> Result<Option<AgeRequest>, String> {
367            Ok(self.requests.lock().unwrap().get(&id).cloned())
368        }
369
370        async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<AgeRequest>, String> {
371            Ok(self
372                .requests
373                .lock()
374                .unwrap()
375                .values()
376                .filter(|r| r.building_id == building_id)
377                .cloned()
378                .collect())
379        }
380
381        async fn find_by_organization(
382            &self,
383            organization_id: Uuid,
384        ) -> Result<Vec<AgeRequest>, String> {
385            Ok(self
386                .requests
387                .lock()
388                .unwrap()
389                .values()
390                .filter(|r| r.organization_id == organization_id)
391                .cloned()
392                .collect())
393        }
394
395        async fn update(&self, age_request: &AgeRequest) -> Result<AgeRequest, String> {
396            self.requests
397                .lock()
398                .unwrap()
399                .insert(age_request.id, age_request.clone());
400            Ok(age_request.clone())
401        }
402
403        async fn delete(&self, id: Uuid) -> Result<bool, String> {
404            Ok(self.requests.lock().unwrap().remove(&id).is_some())
405        }
406
407        async fn add_cosignatory(&self, cosignatory: &AgeRequestCosignatory) -> Result<(), String> {
408            self.cosignatories
409                .lock()
410                .unwrap()
411                .entry(cosignatory.age_request_id)
412                .or_default()
413                .push(cosignatory.clone());
414            Ok(())
415        }
416
417        async fn remove_cosignatory(
418            &self,
419            age_request_id: Uuid,
420            owner_id: Uuid,
421        ) -> Result<bool, String> {
422            let mut map = self.cosignatories.lock().unwrap();
423            if let Some(list) = map.get_mut(&age_request_id) {
424                let before = list.len();
425                list.retain(|c| c.owner_id != owner_id);
426                Ok(list.len() < before)
427            } else {
428                Ok(false)
429            }
430        }
431
432        async fn find_cosignatories(
433            &self,
434            age_request_id: Uuid,
435        ) -> Result<Vec<AgeRequestCosignatory>, String> {
436            Ok(self
437                .cosignatories
438                .lock()
439                .unwrap()
440                .get(&age_request_id)
441                .cloned()
442                .unwrap_or_default())
443        }
444
445        async fn find_expired_deadlines(&self) -> Result<Vec<AgeRequest>, String> {
446            let now = Utc::now();
447            Ok(self
448                .requests
449                .lock()
450                .unwrap()
451                .values()
452                .filter(|r| {
453                    r.status == AgeRequestStatus::Submitted
454                        && r.syndic_deadline_at.map(|d| now > d).unwrap_or(false)
455                })
456                .cloned()
457                .collect())
458        }
459    }
460
461    // ---------------------------------------------------------------------------
462    // Helpers
463    // ---------------------------------------------------------------------------
464
465    fn make_use_cases(repo: Arc<MockAgeRequestRepository>) -> AgeRequestUseCases {
466        AgeRequestUseCases::new(repo)
467    }
468
469    fn org_id() -> Uuid {
470        Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()
471    }
472
473    fn building_id() -> Uuid {
474        Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap()
475    }
476
477    fn creator_id() -> Uuid {
478        Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap()
479    }
480
481    /// Helper: create a Draft AGE request through the use case layer and return its ID.
482    async fn create_draft(uc: &AgeRequestUseCases) -> Uuid {
483        let dto = CreateAgeRequestDto {
484            building_id: building_id(),
485            title: "Remplacement toiture".to_string(),
486            description: Some("Infiltrations importantes".to_string()),
487        };
488        let resp = uc.create(org_id(), creator_id(), dto).await.unwrap();
489        resp.id
490    }
491
492    /// Helper: create a Draft request, open it, add enough cosignatories to reach
493    /// the 1/5 threshold, and return the request ID.
494    async fn create_reached(uc: &AgeRequestUseCases, repo: &MockAgeRequestRepository) -> Uuid {
495        let id = create_draft(uc).await;
496
497        // Open
498        uc.open(id, org_id(), creator_id()).await.unwrap();
499
500        // Add cosignatories totalling >= 20%
501        let owner1 = Uuid::new_v4();
502        let owner2 = Uuid::new_v4();
503        uc.add_cosignatory(
504            id,
505            org_id(),
506            AddCosignatoryDto {
507                owner_id: owner1,
508                shares_pct: 0.12,
509            },
510        )
511        .await
512        .unwrap();
513        uc.add_cosignatory(
514            id,
515            org_id(),
516            AddCosignatoryDto {
517                owner_id: owner2,
518                shares_pct: 0.10,
519            },
520        )
521        .await
522        .unwrap();
523
524        // Verify state is Reached
525        let req = repo.find_by_id(id).await.unwrap().unwrap();
526        assert_eq!(req.status, AgeRequestStatus::Reached);
527
528        id
529    }
530
531    /// Helper: create a Submitted request (threshold reached, then submitted to syndic).
532    async fn create_submitted(uc: &AgeRequestUseCases, repo: &MockAgeRequestRepository) -> Uuid {
533        let id = create_reached(uc, repo).await;
534        uc.submit_to_syndic(id, org_id(), creator_id())
535            .await
536            .unwrap();
537
538        let req = repo.find_by_id(id).await.unwrap().unwrap();
539        assert_eq!(req.status, AgeRequestStatus::Submitted);
540        id
541    }
542
543    // ---------------------------------------------------------------------------
544    // Tests
545    // ---------------------------------------------------------------------------
546
547    #[tokio::test]
548    async fn test_create_age_request_success() {
549        let repo = Arc::new(MockAgeRequestRepository::new());
550        let uc = make_use_cases(repo.clone());
551
552        let dto = CreateAgeRequestDto {
553            building_id: building_id(),
554            title: "Remplacement toiture".to_string(),
555            description: Some("Infiltrations importantes".to_string()),
556        };
557
558        let resp = uc.create(org_id(), creator_id(), dto).await.unwrap();
559
560        assert_eq!(resp.status, "draft");
561        assert_eq!(resp.title, "Remplacement toiture");
562        assert_eq!(resp.organization_id, org_id());
563        assert_eq!(resp.building_id, building_id());
564        assert_eq!(resp.created_by, creator_id());
565        assert_eq!(resp.total_shares_pct, 0.0);
566        assert!(!resp.threshold_reached);
567        assert_eq!(resp.threshold_pct, AgeRequest::DEFAULT_THRESHOLD_PCT);
568        assert!(resp.cosignatories.is_empty());
569
570        // Verify persisted in mock repo
571        let stored = repo.find_by_id(resp.id).await.unwrap();
572        assert!(stored.is_some());
573    }
574
575    #[tokio::test]
576    async fn test_add_cosignatory_threshold_logic() {
577        let repo = Arc::new(MockAgeRequestRepository::new());
578        let uc = make_use_cases(repo.clone());
579
580        let id = create_draft(&uc).await;
581        uc.open(id, org_id(), creator_id()).await.unwrap();
582
583        // First cosignatory: 10% -- not enough for 1/5 threshold
584        let owner1 = Uuid::new_v4();
585        let resp = uc
586            .add_cosignatory(
587                id,
588                org_id(),
589                AddCosignatoryDto {
590                    owner_id: owner1,
591                    shares_pct: 0.10,
592                },
593            )
594            .await
595            .unwrap();
596        assert_eq!(resp.status, "open");
597        assert!(!resp.threshold_reached);
598        assert!((resp.total_shares_pct - 0.10).abs() < 1e-9);
599
600        // Second cosignatory: 12% -- total 22% >= 20% threshold
601        let owner2 = Uuid::new_v4();
602        let resp = uc
603            .add_cosignatory(
604                id,
605                org_id(),
606                AddCosignatoryDto {
607                    owner_id: owner2,
608                    shares_pct: 0.12,
609                },
610            )
611            .await
612            .unwrap();
613        assert_eq!(resp.status, "reached");
614        assert!(resp.threshold_reached);
615        assert!(resp.threshold_reached_at.is_some());
616        assert!((resp.total_shares_pct - 0.22).abs() < 1e-9);
617        assert_eq!(resp.shares_pct_missing, 0.0);
618    }
619
620    #[tokio::test]
621    async fn test_submit_to_syndic_when_threshold_reached() {
622        let repo = Arc::new(MockAgeRequestRepository::new());
623        let uc = make_use_cases(repo.clone());
624
625        let id = create_reached(&uc, &repo).await;
626
627        let resp = uc
628            .submit_to_syndic(id, org_id(), creator_id())
629            .await
630            .unwrap();
631
632        assert_eq!(resp.status, "submitted");
633        assert!(resp.submitted_to_syndic_at.is_some());
634        assert!(resp.syndic_deadline_at.is_some());
635
636        // Deadline = submitted + 15 days
637        let submitted = resp.submitted_to_syndic_at.unwrap();
638        let deadline = resp.syndic_deadline_at.unwrap();
639        let diff = deadline - submitted;
640        assert_eq!(diff.num_days(), AgeRequest::SYNDIC_DEADLINE_DAYS);
641    }
642
643    #[tokio::test]
644    async fn test_submit_to_syndic_rejected_when_threshold_not_reached() {
645        let repo = Arc::new(MockAgeRequestRepository::new());
646        let uc = make_use_cases(repo.clone());
647
648        let id = create_draft(&uc).await;
649        uc.open(id, org_id(), creator_id()).await.unwrap();
650
651        // Add a single small cosignatory (5% < 20%)
652        uc.add_cosignatory(
653            id,
654            org_id(),
655            AddCosignatoryDto {
656                owner_id: Uuid::new_v4(),
657                shares_pct: 0.05,
658            },
659        )
660        .await
661        .unwrap();
662
663        // Attempt submit -- should fail because status is Open, not Reached
664        let result = uc.submit_to_syndic(id, org_id(), creator_id()).await;
665        assert!(result.is_err());
666        let err = result.unwrap_err();
667        assert!(
668            err.contains("Reached"),
669            "Error should mention Reached status requirement, got: {}",
670            err
671        );
672
673        // Verify status unchanged
674        let req = repo.find_by_id(id).await.unwrap().unwrap();
675        assert_eq!(req.status, AgeRequestStatus::Open);
676    }
677
678    #[tokio::test]
679    async fn test_syndic_accepts_request() {
680        let repo = Arc::new(MockAgeRequestRepository::new());
681        let uc = make_use_cases(repo.clone());
682
683        let id = create_submitted(&uc, &repo).await;
684
685        let resp = uc
686            .syndic_response(
687                id,
688                org_id(),
689                SyndicResponseDto {
690                    accepted: true,
691                    notes: Some("Convocation prévue le 15/04".to_string()),
692                },
693            )
694            .await
695            .unwrap();
696
697        assert_eq!(resp.status, "accepted");
698        assert!(resp.syndic_response_at.is_some());
699        assert_eq!(
700            resp.syndic_notes.as_deref(),
701            Some("Convocation prévue le 15/04")
702        );
703    }
704
705    #[tokio::test]
706    async fn test_syndic_rejects_request_with_reason() {
707        let repo = Arc::new(MockAgeRequestRepository::new());
708        let uc = make_use_cases(repo.clone());
709
710        let id = create_submitted(&uc, &repo).await;
711
712        let resp = uc
713            .syndic_response(
714                id,
715                org_id(),
716                SyndicResponseDto {
717                    accepted: false,
718                    notes: Some("Demande insuffisamment motivée".to_string()),
719                },
720            )
721            .await
722            .unwrap();
723
724        assert_eq!(resp.status, "rejected");
725        assert!(resp.syndic_response_at.is_some());
726        assert_eq!(
727            resp.syndic_notes.as_deref(),
728            Some("Demande insuffisamment motivée")
729        );
730    }
731
732    #[tokio::test]
733    async fn test_syndic_rejects_without_reason_fails() {
734        let repo = Arc::new(MockAgeRequestRepository::new());
735        let uc = make_use_cases(repo.clone());
736
737        let id = create_submitted(&uc, &repo).await;
738
739        // Reject with notes = None => should fail (reason is mandatory)
740        let result = uc
741            .syndic_response(
742                id,
743                org_id(),
744                SyndicResponseDto {
745                    accepted: false,
746                    notes: None,
747                },
748            )
749            .await;
750
751        assert!(result.is_err());
752        let err = result.unwrap_err();
753        assert!(
754            err.contains("motif") || err.contains("obligatoire"),
755            "Error should mention mandatory reason, got: {}",
756            err
757        );
758    }
759
760    #[tokio::test]
761    async fn test_withdraw_request_by_initiator() {
762        let repo = Arc::new(MockAgeRequestRepository::new());
763        let uc = make_use_cases(repo.clone());
764
765        let id = create_draft(&uc).await;
766        uc.open(id, org_id(), creator_id()).await.unwrap();
767
768        // Withdraw by initiator
769        let resp = uc.withdraw(id, org_id(), creator_id()).await.unwrap();
770        assert_eq!(resp.status, "withdrawn");
771
772        // Verify persisted
773        let req = repo.find_by_id(id).await.unwrap().unwrap();
774        assert_eq!(req.status, AgeRequestStatus::Withdrawn);
775    }
776
777    #[tokio::test]
778    async fn test_deadline_15_days_logic() {
779        let repo = Arc::new(MockAgeRequestRepository::new());
780        let uc = make_use_cases(repo.clone());
781
782        // Create a request that is already Submitted with an expired deadline
783        // by seeding directly into the mock.
784        let mut req = AgeRequest::new(
785            org_id(),
786            building_id(),
787            "Toiture urgente".to_string(),
788            None,
789            creator_id(),
790        )
791        .unwrap();
792        req.open().unwrap();
793        req.add_cosignatory(Uuid::new_v4(), 0.25).unwrap();
794        req.submit_to_syndic().unwrap();
795
796        // Manually set the deadline to 16 days in the past (expired)
797        let past_submitted = Utc::now() - Duration::days(16);
798        req.submitted_to_syndic_at = Some(past_submitted);
799        req.syndic_deadline_at =
800            Some(past_submitted + Duration::days(AgeRequest::SYNDIC_DEADLINE_DAYS));
801
802        let request_id = req.id;
803        repo.seed(req);
804
805        // trigger_auto_convocation should succeed because deadline is past
806        let resp = uc
807            .trigger_auto_convocation(request_id, org_id())
808            .await
809            .unwrap();
810
811        assert_eq!(resp.status, "expired");
812        assert!(resp.auto_convocation_triggered);
813
814        // Also verify find_expired_deadlines picks it up (it won't anymore
815        // since status is now Expired, but verify the flow was correct)
816        let req = repo.find_by_id(request_id).await.unwrap().unwrap();
817        assert_eq!(req.status, AgeRequestStatus::Expired);
818        assert!(req.auto_convocation_triggered);
819    }
820}