Skip to main content

koprogo_api/application/use_cases/
convocation_use_cases.rs

1use crate::application::dto::{
2    ConvocationRecipientResponse, ConvocationResponse, CreateConvocationRequest,
3    EligibleRecipientResponse, RecipientTrackingSummaryResponse, ScheduleConvocationRequest,
4    SendConvocationRequest,
5};
6use crate::application::error::AppError;
7use crate::application::ports::{
8    BuildingRepository, ConvocationRecipientRepository, ConvocationRepository, MeetingRepository,
9    OwnerRepository, UserRepository,
10};
11use crate::domain::entities::{
12    AttendanceStatus, Convocation, ConvocationRecipient, QualiteDuMandataire,
13};
14use crate::domain::services::ConvocationExporter;
15use chrono::Utc;
16use std::sync::Arc;
17use uuid::Uuid;
18
19pub struct ConvocationUseCases {
20    convocation_repository: Arc<dyn ConvocationRepository>,
21    recipient_repository: Arc<dyn ConvocationRecipientRepository>,
22    owner_repository: Arc<dyn OwnerRepository>,
23    building_repository: Arc<dyn BuildingRepository>,
24    meeting_repository: Arc<dyn MeetingRepository>,
25    /// Sert à déduire les destinataires quand l'appelant n'en fournit pas.
26    /// `OwnerRepository` ne sait pas lister par immeuble, et aucune route
27    /// `GET /buildings/{id}/owners` n'existe.
28    unit_owner_repository: Arc<dyn crate::application::ports::UnitOwnerRepository>,
29    /// Sert à établir la QUALITÉ d'un mandataire pressenti, pas son identité.
30    ///
31    /// L'Art. 3.87 § 7 interdit au syndic d'intervenir comme mandataire. Le
32    /// savoir demande de remonter `owners.user_id` puis le rôle de cet
33    /// utilisateur : le dépôt de copropriétaires seul ne le dit pas.
34    user_repository: Arc<dyn UserRepository>,
35}
36
37impl ConvocationUseCases {
38    pub fn new(
39        convocation_repository: Arc<dyn ConvocationRepository>,
40        recipient_repository: Arc<dyn ConvocationRecipientRepository>,
41        owner_repository: Arc<dyn OwnerRepository>,
42        building_repository: Arc<dyn BuildingRepository>,
43        meeting_repository: Arc<dyn MeetingRepository>,
44        unit_owner_repository: Arc<dyn crate::application::ports::UnitOwnerRepository>,
45        user_repository: Arc<dyn UserRepository>,
46    ) -> Self {
47        Self {
48            convocation_repository,
49            recipient_repository,
50            owner_repository,
51            building_repository,
52            meeting_repository,
53            unit_owner_repository,
54            user_repository,
55        }
56    }
57
58    /// Create a new convocation
59    pub async fn create_convocation(
60        &self,
61        organization_id: Uuid,
62        request: CreateConvocationRequest,
63        created_by: Uuid,
64    ) -> Result<ConvocationResponse, String> {
65        // La convocation est un acte de l'ACP : ses frais sont à sa charge
66        // (Art. 3.87 § 3), et elle survit au mandat qui l'a émise (ADR-0045).
67        let building = self
68            .building_repository
69            .find_by_id(request.building_id)
70            .await?
71            .ok_or_else(|| "Immeuble introuvable".to_string())?;
72
73        // Create domain entity (validates legal deadline)
74        let convocation = Convocation::new(
75            building.acp_id,
76            organization_id,
77            request.building_id,
78            request.meeting_id,
79            request.meeting_type,
80            request.meeting_date,
81            request.language,
82            created_by,
83        )?;
84
85        let created = self.convocation_repository.create(&convocation).await?;
86
87        Ok(ConvocationResponse::from(created))
88    }
89
90    /// Identifiants des copropriétaires actifs d'un immeuble, dédupliqués.
91    ///
92    /// Partagé par `send_convocation` (destinataires par défaut) et
93    /// `list_eligible_recipients` (écran de sélection, #780 verrou 1 / #784) :
94    /// la question « qui peut recevoir cette convocation ? » ne doit être
95    /// répondue qu'à un seul endroit.
96    async fn active_owner_ids(&self, building_id: Uuid) -> Result<Vec<Uuid>, String> {
97        let detenteurs = self
98            .unit_owner_repository
99            .find_active_by_building(building_id)
100            .await?;
101        // Un copropriétaire détenant plusieurs lots ne doit être convoqué
102        // qu'une fois.
103        let mut vus = std::collections::BTreeSet::new();
104        Ok(detenteurs
105            .into_iter()
106            .filter_map(|(_unit_id, owner_id, _quota)| vus.insert(owner_id).then_some(owner_id))
107            .collect())
108    }
109
110    /// Les copropriétaires qu'une convocation pour cet immeuble toucherait.
111    ///
112    /// Avant cet écran, « 0 destinataire » était un libellé sans contrôle
113    /// pour le constituer : le syndic découvrait qui avait été convoqué APRÈS
114    /// l'envoi, jamais avant (#780 verrou 1). `send_convocation` déduit déjà
115    /// cette même liste par défaut ; l'exposer en lecture permet de la
116    /// montrer et de la corriger avant que l'envoi ne fasse courir le délai
117    /// légal de l'Art. 3.87 § 3.
118    pub async fn list_eligible_recipients(
119        &self,
120        building_id: Uuid,
121    ) -> Result<Vec<EligibleRecipientResponse>, AppError> {
122        let owner_ids = self.active_owner_ids(building_id).await?;
123        let mut destinataires = Vec::with_capacity(owner_ids.len());
124        for owner_id in owner_ids {
125            let owner = self
126                .owner_repository
127                .find_by_id(owner_id)
128                .await?
129                .ok_or_else(|| AppError::NotFound(format!("Owner {}", owner_id)))?;
130            destinataires.push(EligibleRecipientResponse {
131                owner_id: owner.id,
132                full_name: format!("{} {}", owner.first_name, owner.last_name),
133                email: owner.email,
134            });
135        }
136        destinataires.sort_by(|a, b| a.full_name.cmp(&b.full_name));
137        Ok(destinataires)
138    }
139
140    /// Get convocation by ID
141    pub async fn get_convocation(&self, id: Uuid) -> Result<ConvocationResponse, String> {
142        let convocation = self
143            .convocation_repository
144            .find_by_id(id)
145            .await?
146            .ok_or_else(|| format!("Convocation not found: {}", id))?;
147
148        Ok(ConvocationResponse::from(convocation))
149    }
150
151    /// Get convocation by meeting ID
152    pub async fn get_convocation_by_meeting(
153        &self,
154        meeting_id: Uuid,
155    ) -> Result<Option<ConvocationResponse>, String> {
156        let convocation = self
157            .convocation_repository
158            .find_by_meeting_id(meeting_id)
159            .await?;
160
161        Ok(convocation.map(ConvocationResponse::from))
162    }
163
164    /// List convocations for a building
165    pub async fn list_building_convocations(
166        &self,
167        building_id: Uuid,
168    ) -> Result<Vec<ConvocationResponse>, String> {
169        let convocations = self
170            .convocation_repository
171            .find_by_building(building_id)
172            .await?;
173
174        Ok(convocations
175            .into_iter()
176            .map(ConvocationResponse::from)
177            .collect())
178    }
179
180    /// List convocations for an organization
181    pub async fn list_organization_convocations(
182        &self,
183        organization_id: Uuid,
184    ) -> Result<Vec<ConvocationResponse>, String> {
185        let convocations = self
186            .convocation_repository
187            .find_by_organization(organization_id)
188            .await?;
189
190        Ok(convocations
191            .into_iter()
192            .map(ConvocationResponse::from)
193            .collect())
194    }
195
196    /// Schedule convocation to be sent at specific date
197    pub async fn schedule_convocation(
198        &self,
199        id: Uuid,
200        request: ScheduleConvocationRequest,
201    ) -> Result<ConvocationResponse, String> {
202        let mut convocation = self
203            .convocation_repository
204            .find_by_id(id)
205            .await?
206            .ok_or_else(|| format!("Convocation not found: {}", id))?;
207
208        convocation.schedule(request.send_date)?;
209
210        let updated = self.convocation_repository.update(&convocation).await?;
211
212        Ok(ConvocationResponse::from(updated))
213    }
214
215    /// Send convocation to owners (generates PDF, creates recipients, sends emails)
216    /// This would typically be called by a background job or email service
217    pub async fn send_convocation(
218        &self,
219        id: Uuid,
220        request: SendConvocationRequest,
221    ) -> Result<ConvocationResponse, String> {
222        // Une sélection explicitement vide est un choix, pas une absence de
223        // choix : `None` (ancien client, champ jamais rempli) déduit tous les
224        // copropriétaires actifs par défaut, mais `Some(vec![])` dit que le
225        // syndic a vu l'écran de sélection et n'a coché personne. Confondre
226        // les deux ferait ignorer silencieusement un renoncement délibéré et
227        // convoquer tout le monde quand même — pire que le défaut d'origine,
228        // qui au moins ne décidait rien à la place du syndic (#780, @negative).
229        if matches!(&request.recipient_owner_ids, Some(ids) if ids.is_empty()) {
230            return Err(
231                "Sélectionnez au moins un destinataire avant d'envoyer la convocation.".to_string(),
232            );
233        }
234
235        let mut convocation = self
236            .convocation_repository
237            .find_by_id(id)
238            .await?
239            .ok_or_else(|| format!("Convocation not found: {}", id))?;
240
241        // Fetch building for PDF generation
242        let building = self
243            .building_repository
244            .find_by_id(convocation.building_id)
245            .await?
246            .ok_or_else(|| format!("Building not found: {}", convocation.building_id))?;
247
248        // Fetch meeting for PDF generation
249        let meeting = self
250            .meeting_repository
251            .find_by_id(convocation.meeting_id)
252            .await?
253            .ok_or_else(|| format!("Meeting not found: {}", convocation.meeting_id))?;
254
255        // Generate PDF
256        let pdf_bytes = ConvocationExporter::export_to_pdf(&building, &meeting, &convocation)
257            .map_err(|e| format!("Failed to generate PDF: {}", e))?;
258
259        // Save PDF to file
260        let upload_dir =
261            std::env::var("UPLOAD_DIR").unwrap_or_else(|_| "/tmp/koprogo-uploads".to_string());
262        let pdf_file_path = format!("{}/convocations/conv-{}.pdf", upload_dir, id);
263        ConvocationExporter::save_to_file(&pdf_bytes, &pdf_file_path)
264            .map_err(|e| format!("Failed to save PDF: {}", e))?;
265
266        // Les destinataires : ceux qu'on nous donne (liste non vide, déjà
267        // vérifié plus haut), ou — champ absent, ancien client — tous les
268        // copropriétaires actifs de l'immeuble par défaut.
269        let destinataires: Vec<Uuid> = match &request.recipient_owner_ids {
270            Some(ids) => ids.clone(),
271            None => self.active_owner_ids(convocation.building_id).await?,
272        };
273
274        // Convoquer personne n'est pas convoquer.
275        //
276        // `mark_sent(pdf, 0)` marquait la convocation comme envoyée avec zéro
277        // destinataire : un envoi à personne était compté comme régulier, et
278        // la condition de clôture « convocations envoyées » se trouvait
279        // satisfaite sans que quiconque ait été prévenu.
280        if destinataires.is_empty() {
281            return Err(
282                "Aucun copropriétaire à convoquer : cet immeuble n'a pas de lot \
283                 attribué. Rattachez les copropriétaires à leurs lots avant de \
284                 convoquer l'assemblée."
285                    .to_string(),
286            );
287        }
288
289        // Fetch owner emails
290        let mut recipients = Vec::new();
291        for owner_id in &destinataires {
292            let owner = self
293                .owner_repository
294                .find_by_id(*owner_id)
295                .await?
296                .ok_or_else(|| format!("Owner not found: {}", owner_id))?;
297
298            let mut recipient = ConvocationRecipient::new(id, *owner_id, owner.email)?;
299            recipient.mark_email_sent();
300            recipients.push(recipient);
301        }
302
303        // Create recipients in database (bulk insert)
304        let created_recipients = self.recipient_repository.create_many(&recipients).await?;
305
306        // Mark convocation as sent
307        convocation.mark_sent(pdf_file_path, created_recipients.len() as i32)?;
308
309        let updated = self.convocation_repository.update(&convocation).await?;
310
311        Ok(ConvocationResponse::from(updated))
312    }
313
314    /// Mark recipient email as sent
315    pub async fn mark_recipient_email_sent(
316        &self,
317        recipient_id: Uuid,
318    ) -> Result<ConvocationRecipientResponse, String> {
319        let mut recipient = self
320            .recipient_repository
321            .find_by_id(recipient_id)
322            .await?
323            .ok_or_else(|| format!("Recipient not found: {}", recipient_id))?;
324
325        recipient.mark_email_sent();
326
327        let updated = self.recipient_repository.update(&recipient).await?;
328
329        Ok(ConvocationRecipientResponse::from(updated))
330    }
331
332    /// Mark recipient email as opened (tracking pixel or link click)
333    pub async fn mark_recipient_email_opened(
334        &self,
335        recipient_id: Uuid,
336    ) -> Result<ConvocationRecipientResponse, String> {
337        let mut recipient = self
338            .recipient_repository
339            .find_by_id(recipient_id)
340            .await?
341            .ok_or_else(|| format!("Recipient not found: {}", recipient_id))?;
342
343        recipient.mark_email_opened()?;
344
345        let updated = self.recipient_repository.update(&recipient).await?;
346
347        // Update convocation tracking counts
348        self.update_convocation_tracking(recipient.convocation_id)
349            .await?;
350
351        Ok(ConvocationRecipientResponse::from(updated))
352    }
353
354    /// Update recipient attendance status
355    pub async fn update_recipient_attendance(
356        &self,
357        recipient_id: Uuid,
358        status: AttendanceStatus,
359    ) -> Result<ConvocationRecipientResponse, String> {
360        let mut recipient = self
361            .recipient_repository
362            .find_by_id(recipient_id)
363            .await?
364            .ok_or_else(|| format!("Recipient not found: {}", recipient_id))?;
365
366        recipient.update_attendance_status(status)?;
367
368        let updated = self.recipient_repository.update(&recipient).await?;
369
370        // Update convocation tracking counts
371        self.update_convocation_tracking(recipient.convocation_id)
372            .await?;
373
374        Ok(ConvocationRecipientResponse::from(updated))
375    }
376
377    /// Set proxy delegation for recipient
378    pub async fn set_recipient_proxy(
379        &self,
380        recipient_id: Uuid,
381        proxy_owner_id: Uuid,
382    ) -> Result<ConvocationRecipientResponse, String> {
383        let mut recipient = self
384            .recipient_repository
385            .find_by_id(recipient_id)
386            .await?
387            .ok_or_else(|| format!("Recipient not found: {}", recipient_id))?;
388
389        let qualite = self.qualite_du_mandataire(proxy_owner_id).await?;
390        recipient.set_proxy(proxy_owner_id, qualite)?;
391
392        let updated = self.recipient_repository.update(&recipient).await?;
393
394        Ok(ConvocationRecipientResponse::from(updated))
395    }
396
397    /// Établit ce qu'est un mandataire pressenti, pour l'Art. 3.87 § 7.
398    ///
399    /// La chaîne est en deux sauts : `owners.user_id`, puis le rôle de cet
400    /// utilisateur. Aucun des deux n'est garanti.
401    ///
402    /// **Un copropriétaire sans compte est un copropriétaire ordinaire.** C'est
403    /// le cas le plus fréquent — `Owner::user_id` est `Option`, renseigné
404    /// seulement quand un accès au portail est ouvert. En faire un refus
405    /// interdirait la procuration à presque tout le monde, ce que la loi
406    /// n'exige pas ; en faire un doute silencieux masquerait la règle. On
407    /// conclut donc `Coproprietaire`, ce qui est la vérité : rien n'établit
408    /// qu'il soit le syndic.
409    ///
410    /// Le mandataire introuvable, en revanche, est une erreur : on n'enregistre
411    /// pas une procuration au profit de quelqu'un qui n'existe pas.
412    async fn qualite_du_mandataire(
413        &self,
414        proxy_owner_id: Uuid,
415    ) -> Result<QualiteDuMandataire, String> {
416        let mandataire = self
417            .owner_repository
418            .find_by_id(proxy_owner_id)
419            .await?
420            .ok_or_else(|| format!("Mandataire introuvable : {}", proxy_owner_id))?;
421
422        let Some(user_id) = mandataire.user_id else {
423            return Ok(QualiteDuMandataire::Coproprietaire);
424        };
425
426        let Some(utilisateur) = self.user_repository.find_by_id(user_id).await? else {
427            return Ok(QualiteDuMandataire::Coproprietaire);
428        };
429
430        Ok(match utilisateur.role {
431            crate::domain::plateforme::user::UserRole::Syndic => QualiteDuMandataire::Syndic,
432            _ => QualiteDuMandataire::Coproprietaire,
433        })
434    }
435
436    /// Send reminders to recipients who haven't opened the convocation (J-3)
437    /// This would typically be called by a background job
438    pub async fn send_reminders(
439        &self,
440        convocation_id: Uuid,
441    ) -> Result<Vec<ConvocationRecipientResponse>, String> {
442        // Get recipients who need reminder
443        let recipients = self
444            .recipient_repository
445            .find_needing_reminder(convocation_id)
446            .await?;
447
448        let mut updated_recipients = Vec::new();
449
450        for mut recipient in recipients {
451            recipient.mark_reminder_sent()?;
452            let updated = self.recipient_repository.update(&recipient).await?;
453            updated_recipients.push(ConvocationRecipientResponse::from(updated));
454        }
455
456        // Mark convocation as reminder sent
457        if !updated_recipients.is_empty() {
458            let mut convocation = self
459                .convocation_repository
460                .find_by_id(convocation_id)
461                .await?
462                .ok_or_else(|| format!("Convocation not found: {}", convocation_id))?;
463
464            convocation.mark_reminder_sent()?;
465            self.convocation_repository.update(&convocation).await?;
466        }
467
468        Ok(updated_recipients)
469    }
470
471    /// Get tracking summary for convocation
472    pub async fn get_tracking_summary(
473        &self,
474        convocation_id: Uuid,
475    ) -> Result<RecipientTrackingSummaryResponse, String> {
476        let summary = self
477            .recipient_repository
478            .get_tracking_summary(convocation_id)
479            .await?;
480
481        Ok(RecipientTrackingSummaryResponse::new(
482            summary.total_count,
483            summary.opened_count,
484            summary.will_attend_count,
485            summary.will_not_attend_count,
486            summary.attended_count,
487            summary.did_not_attend_count,
488            summary.pending_count,
489            summary.failed_email_count,
490        ))
491    }
492
493    /// Get all recipients for a convocation
494    pub async fn list_convocation_recipients(
495        &self,
496        convocation_id: Uuid,
497    ) -> Result<Vec<ConvocationRecipientResponse>, String> {
498        let recipients = self
499            .recipient_repository
500            .find_by_convocation(convocation_id)
501            .await?;
502
503        Ok(recipients
504            .into_iter()
505            .map(ConvocationRecipientResponse::from)
506            .collect())
507    }
508
509    /// Cancel convocation
510    pub async fn cancel_convocation(&self, id: Uuid) -> Result<ConvocationResponse, String> {
511        let mut convocation = self
512            .convocation_repository
513            .find_by_id(id)
514            .await?
515            .ok_or_else(|| format!("Convocation not found: {}", id))?;
516
517        convocation.cancel()?;
518
519        let updated = self.convocation_repository.update(&convocation).await?;
520
521        Ok(ConvocationResponse::from(updated))
522    }
523
524    /// Delete convocation (and all recipients via CASCADE)
525    pub async fn delete_convocation(&self, id: Uuid) -> Result<bool, String> {
526        self.convocation_repository.delete(id).await
527    }
528
529    /// Process scheduled convocations (called by background job)
530    /// Returns list of convocations that were sent
531    pub async fn process_scheduled_convocations(&self) -> Result<Vec<ConvocationResponse>, String> {
532        let now = Utc::now();
533        let scheduled = self
534            .convocation_repository
535            .find_pending_scheduled(now)
536            .await?;
537
538        let mut sent = Vec::new();
539
540        for convocation in scheduled {
541            // This would trigger PDF generation and email sending
542            // For now, we just return the list that needs processing
543            sent.push(ConvocationResponse::from(convocation));
544        }
545
546        Ok(sent)
547    }
548
549    /// Process reminder sending (called by background job)
550    /// Returns list of convocations that had reminders sent
551    pub async fn process_reminder_sending(&self) -> Result<Vec<ConvocationResponse>, String> {
552        let now = Utc::now();
553        let needing_reminder = self
554            .convocation_repository
555            .find_needing_reminder(now)
556            .await?;
557
558        let mut processed = Vec::new();
559
560        for convocation in needing_reminder {
561            // Send reminders to recipients
562            self.send_reminders(convocation.id).await?;
563            processed.push(ConvocationResponse::from(convocation));
564        }
565
566        Ok(processed)
567    }
568
569    /// Schedule a second convocation after quorum not reached
570    /// Art. 3.87 §5 CC: "La deuxième assemblée délibère valablement quel que soit le nombre de présents."
571    ///
572    /// # Arguments
573    /// * `first_meeting_id` - ID of the first meeting where quorum was not reached
574    /// * `new_meeting_id` - ID of the new meeting scheduled for the second convocation
575    /// * `new_meeting_date` - Date of the second meeting (must be ≥15 days after first meeting)
576    /// * `language` - Language for the convocation (FR/NL/DE/EN)
577    /// * `created_by` - User ID creating the second convocation
578    ///
579    /// # Returns
580    /// Result with the created second convocation
581    pub async fn schedule_second_convocation(
582        &self,
583        organization_id: Uuid,
584        building_id: Uuid,
585        first_meeting_id: Uuid,
586        new_meeting_id: Uuid,
587        new_meeting_date: chrono::DateTime<chrono::Utc>,
588        language: String,
589        created_by: Uuid,
590    ) -> Result<ConvocationResponse, String> {
591        // Fetch the first meeting to get its meeting date
592        let first_meeting = self
593            .meeting_repository
594            .find_by_id(first_meeting_id)
595            .await?
596            .ok_or_else(|| format!("First meeting not found: {}", first_meeting_id))?;
597
598        let building = self
599            .building_repository
600            .find_by_id(building_id)
601            .await?
602            .ok_or_else(|| "Immeuble introuvable".to_string())?;
603
604        // Create the second convocation using the domain entity constructor
605        // This validates that the second meeting is at least 15 days after the first
606        let second_convocation = Convocation::new_second_convocation(
607            building.acp_id,
608            organization_id,
609            building_id,
610            new_meeting_id,
611            first_meeting_id,
612            first_meeting.scheduled_date,
613            new_meeting_date,
614            language,
615            created_by,
616        )?;
617
618        let created = self
619            .convocation_repository
620            .create(&second_convocation)
621            .await?;
622
623        Ok(ConvocationResponse::from(created))
624    }
625
626    /// Internal helper: Update convocation tracking counts from recipients
627    async fn update_convocation_tracking(&self, convocation_id: Uuid) -> Result<(), String> {
628        let summary = self
629            .recipient_repository
630            .get_tracking_summary(convocation_id)
631            .await?;
632
633        let mut convocation = self
634            .convocation_repository
635            .find_by_id(convocation_id)
636            .await?
637            .ok_or_else(|| format!("Convocation not found: {}", convocation_id))?;
638
639        convocation.update_tracking_counts(
640            summary.opened_count as i32,
641            summary.will_attend_count as i32,
642            summary.will_not_attend_count as i32,
643        );
644
645        self.convocation_repository.update(&convocation).await?;
646
647        Ok(())
648    }
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654    use crate::application::dto::{BuildingFilters, OwnerFilters, PageRequest};
655    use crate::application::ports::{
656        ConvocationRecipientRepository, ConvocationRepository, RecipientTrackingSummary,
657    };
658    use crate::domain::entities::{
659        AttendanceStatus, Building, Convocation, ConvocationRecipient, ConvocationStatus,
660        ConvocationType, Meeting, Owner,
661    };
662    use async_trait::async_trait;
663    use chrono::{Duration, Utc};
664    use mockall::mock;
665    use std::sync::Arc;
666    use uuid::Uuid;
667
668    // ---------------------------------------------------------------------------
669    // Mock definitions using mockall::mock!
670    // ---------------------------------------------------------------------------
671
672    mock! {
673        ConvRepo {}
674
675        #[async_trait]
676        impl ConvocationRepository for ConvRepo {
677            async fn create(&self, convocation: &Convocation) -> Result<Convocation, String>;
678            async fn find_by_id(&self, id: Uuid) -> Result<Option<Convocation>, String>;
679            async fn find_by_meeting_id(&self, meeting_id: Uuid) -> Result<Option<Convocation>, String>;
680            async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Convocation>, String>;
681            async fn find_by_organization(&self, organization_id: Uuid) -> Result<Vec<Convocation>, String>;
682            async fn find_by_status(&self, organization_id: Uuid, status: ConvocationStatus) -> Result<Vec<Convocation>, String>;
683            async fn find_pending_scheduled(&self, now: chrono::DateTime<Utc>) -> Result<Vec<Convocation>, String>;
684            async fn find_needing_reminder(&self, now: chrono::DateTime<Utc>) -> Result<Vec<Convocation>, String>;
685            async fn update(&self, convocation: &Convocation) -> Result<Convocation, String>;
686            async fn delete(&self, id: Uuid) -> Result<bool, String>;
687            async fn count_by_building(&self, building_id: Uuid) -> Result<i64, String>;
688            async fn count_by_status(&self, organization_id: Uuid, status: ConvocationStatus) -> Result<i64, String>;
689        }
690    }
691
692    mock! {
693        RecipientRepo {}
694
695        #[async_trait]
696        impl ConvocationRecipientRepository for RecipientRepo {
697            async fn create(&self, recipient: &ConvocationRecipient) -> Result<ConvocationRecipient, String>;
698            async fn create_many(&self, recipients: &[ConvocationRecipient]) -> Result<Vec<ConvocationRecipient>, String>;
699            async fn find_by_id(&self, id: Uuid) -> Result<Option<ConvocationRecipient>, String>;
700            async fn find_by_convocation(&self, convocation_id: Uuid) -> Result<Vec<ConvocationRecipient>, String>;
701            async fn find_by_convocation_and_owner(&self, convocation_id: Uuid, owner_id: Uuid) -> Result<Option<ConvocationRecipient>, String>;
702            async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<ConvocationRecipient>, String>;
703            async fn find_by_attendance_status(&self, convocation_id: Uuid, status: AttendanceStatus) -> Result<Vec<ConvocationRecipient>, String>;
704            async fn find_needing_reminder(&self, convocation_id: Uuid) -> Result<Vec<ConvocationRecipient>, String>;
705            async fn find_failed_emails(&self, convocation_id: Uuid) -> Result<Vec<ConvocationRecipient>, String>;
706            async fn update(&self, recipient: &ConvocationRecipient) -> Result<ConvocationRecipient, String>;
707            async fn delete(&self, id: Uuid) -> Result<bool, String>;
708            async fn count_by_convocation(&self, convocation_id: Uuid) -> Result<i64, String>;
709            async fn count_opened(&self, convocation_id: Uuid) -> Result<i64, String>;
710            async fn count_by_attendance_status(&self, convocation_id: Uuid, status: AttendanceStatus) -> Result<i64, String>;
711            async fn get_tracking_summary(&self, convocation_id: Uuid) -> Result<RecipientTrackingSummary, String>;
712        }
713    }
714
715    mock! {
716        OwnerRepo {}
717
718        #[async_trait]
719        impl OwnerRepository for OwnerRepo {
720            async fn create(&self, owner: &Owner) -> Result<Owner, String>;
721            async fn find_by_id(&self, id: Uuid) -> Result<Option<Owner>, String>;
722            async fn find_by_user_id(&self, user_id: Uuid) -> Result<Option<Owner>, String>;
723            async fn find_by_user_id_and_organization(&self, user_id: Uuid, organization_id: Uuid) -> Result<Option<Owner>, String>;
724            async fn find_by_email(&self, email: &str) -> Result<Option<Owner>, String>;
725            async fn find_all(&self) -> Result<Vec<Owner>, String>;
726            async fn find_all_paginated(&self, page_request: &PageRequest, filters: &OwnerFilters) -> Result<(Vec<Owner>, i64), String>;
727            async fn update(&self, owner: &Owner) -> Result<Owner, String>;
728            async fn delete(&self, id: Uuid) -> Result<bool, String>;
729            async fn set_user_link(&self, owner_id: Uuid, user_id: Option<Uuid>) -> Result<bool, String>;
730        }
731    }
732
733    mock! {
734        BuildingRepo {}
735
736        #[async_trait]
737        impl BuildingRepository for BuildingRepo {
738            async fn create(&self, building: &Building) -> Result<Building, String>;
739            async fn find_by_id(&self, id: Uuid) -> Result<Option<Building>, String>;
740            async fn find_all(&self) -> Result<Vec<Building>, String>;
741            async fn find_all_paginated(&self, page_request: &PageRequest, filters: &BuildingFilters) -> Result<(Vec<Building>, i64), String>;
742            async fn update(&self, building: &Building) -> Result<Building, String>;
743            async fn delete(&self, id: Uuid) -> Result<bool, String>;
744            async fn find_by_slug(&self, slug: &str) -> Result<Option<Building>, String>;
745            async fn find_by_id_with_metrics(
746                &self,
747                id: Uuid,
748            ) -> Result<Option<(Building, crate::domain::entities::BuildingMetrics)>, String>;
749        }
750    }
751
752    mock! {
753        UnitOwnerRepo {}
754
755        #[async_trait]
756        impl crate::application::ports::UnitOwnerRepository for UnitOwnerRepo {
757            async fn create(&self, unit_owner: &crate::domain::entities::UnitOwner) -> Result<crate::domain::entities::UnitOwner, String>;
758            async fn find_by_id(&self, id: Uuid) -> Result<Option<crate::domain::entities::UnitOwner>, String>;
759            async fn find_current_owners_by_unit(&self, unit_id: Uuid) -> Result<Vec<crate::domain::entities::UnitOwner>, String>;
760            async fn find_current_units_by_owner(&self, owner_id: Uuid) -> Result<Vec<crate::domain::entities::UnitOwner>, String>;
761            async fn find_all_owners_by_unit(&self, unit_id: Uuid) -> Result<Vec<crate::domain::entities::UnitOwner>, String>;
762            async fn find_all_units_by_owner(&self, owner_id: Uuid) -> Result<Vec<crate::domain::entities::UnitOwner>, String>;
763            async fn update(&self, unit_owner: &crate::domain::entities::UnitOwner) -> Result<crate::domain::entities::UnitOwner, String>;
764            async fn delete(&self, id: Uuid) -> Result<(), String>;
765            async fn has_active_owners(&self, unit_id: Uuid) -> Result<bool, String>;
766            async fn get_total_ownership_percentage(&self, unit_id: Uuid) -> Result<rust_decimal::Decimal, String>;
767            async fn find_active_by_unit_and_owner(&self, unit_id: Uuid, owner_id: Uuid) -> Result<Option<crate::domain::entities::UnitOwner>, String>;
768            async fn find_active_by_building(&self, building_id: Uuid) -> Result<Vec<(Uuid, Uuid, rust_decimal::Decimal)>, String>;
769            async fn find_active_quota_shares_by_building(&self, building_id: Uuid) -> Result<Vec<(Uuid, Uuid, rust_decimal::Decimal)>, String>;
770            async fn find_voting_holders_by_unit(&self, unit_id: Uuid) -> Result<Vec<crate::domain::copropriete::LotHolder>, String>;
771            async fn is_voting_representative(&self, unit_owner_id: Uuid) -> Result<bool, String>;
772            async fn set_voting_representative(&self, unit_owner_id: Uuid) -> Result<(), String>;
773        }
774    }
775
776    mock! {
777        UserRepo {}
778
779        #[async_trait]
780        impl UserRepository for UserRepo {
781            async fn create(&self, user: &crate::domain::plateforme::user::User) -> Result<crate::domain::plateforme::user::User, String>;
782            async fn find_by_id(&self, id: Uuid) -> Result<Option<crate::domain::plateforme::user::User>, String>;
783            async fn find_by_email(&self, email: &str) -> Result<Option<crate::domain::plateforme::user::User>, String>;
784            async fn find_all(&self) -> Result<Vec<crate::domain::plateforme::user::User>, String>;
785            async fn find_page(
786                &self,
787                recherche: Option<String>,
788                role: Option<String>,
789                limit: i64,
790                offset: i64,
791            ) -> Result<Vec<crate::domain::plateforme::user::User>, String>;
792            async fn count_matching(
793                &self,
794                recherche: Option<String>,
795                role: Option<String>,
796            ) -> Result<i64, String>;
797            async fn find_by_organization(&self, org_id: Uuid) -> Result<Vec<crate::domain::plateforme::user::User>, String>;
798            async fn update(&self, user: &crate::domain::plateforme::user::User) -> Result<crate::domain::plateforme::user::User, String>;
799            async fn update_password(&self, id: Uuid, password_hash: &str) -> Result<bool, String>;
800            async fn activate(&self, id: Uuid) -> Result<Option<crate::domain::plateforme::user::User>, String>;
801            async fn deactivate(&self, id: Uuid) -> Result<Option<crate::domain::plateforme::user::User>, String>;
802            async fn delete(&self, id: Uuid) -> Result<bool, String>;
803            async fn count_by_organization(&self, org_id: Uuid) -> Result<i64, String>;
804        }
805    }
806
807    mock! {
808        MeetingRepo {}
809
810        #[async_trait]
811        impl MeetingRepository for MeetingRepo {
812            async fn create(&self, meeting: &Meeting) -> Result<Meeting, String>;
813            async fn find_by_id(&self, id: Uuid) -> Result<Option<Meeting>, String>;
814            async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Meeting>, String>;
815            async fn update(&self, meeting: &Meeting) -> Result<Meeting, String>;
816            async fn delete(&self, id: Uuid) -> Result<bool, String>;
817            async fn find_all_paginated(&self, page_request: &PageRequest, organization_id: Option<Uuid>) -> Result<(Vec<Meeting>, i64), String>;
818        }
819    }
820
821    // ---------------------------------------------------------------------------
822    // Helpers
823    // ---------------------------------------------------------------------------
824
825    /// Build a ConvocationUseCases with the given mocks, using defaults (no-op) for the rest.
826    fn make_use_cases(
827        conv_repo: MockConvRepo,
828        recip_repo: MockRecipientRepo,
829        owner_repo: MockOwnerRepo,
830        mut building_repo: MockBuildingRepo,
831        meeting_repo: MockMeetingRepo,
832    ) -> ConvocationUseCases {
833        // Repli : la convocation résout désormais l'ACP de son immeuble
834        // (Art. 3.87 § 3, ADR-0045). Les tests qui posent leur propre
835        // attente la voient prise en compte d'abord ; les autres obtiennent
836        // un immeuble quelconque plutôt qu'une panique de mock.
837        building_repo.expect_find_by_id().returning(|_| {
838            Ok(Some(
839                Building::new(
840                    Uuid::new_v4(),
841                    "Résidence du Parc".to_string(),
842                    "12 Rue de la Loi".to_string(),
843                    "Brussels".to_string(),
844                    "1000".to_string(),
845                    "Belgium".to_string(),
846                    10,
847                    1000,
848                    Some(2015),
849                )
850                .expect("immeuble valide"),
851            ))
852        });
853        // Par défaut, l'immeuble a deux copropriétaires : c'est ce que le
854        // serveur déduit quand l'appelant ne fournit pas de destinataires.
855        let mut unit_owner_repo = MockUnitOwnerRepo::new();
856        unit_owner_repo
857            .expect_find_active_by_building()
858            .returning(|_| {
859                Ok(vec![
860                    (
861                        Uuid::new_v4(),
862                        Uuid::new_v4(),
863                        rust_decimal::Decimal::from(500),
864                    ),
865                    (
866                        Uuid::new_v4(),
867                        Uuid::new_v4(),
868                        rust_decimal::Decimal::from(500),
869                    ),
870                ])
871            });
872
873        // Par défaut, aucun mandataire n'est rattaché à un compte : c'est le
874        // cas ordinaire (`Owner::user_id` est `Option`), et il conclut
875        // `Coproprietaire`. Les tests qui éprouvent l'Art. 3.87 § 7 posent
876        // leur propre `MockUserRepo` via `make_use_cases_avec_utilisateurs`.
877        let mut user_repo = MockUserRepo::new();
878        user_repo.expect_find_by_id().returning(|_| Ok(None));
879
880        ConvocationUseCases::new(
881            Arc::new(conv_repo),
882            Arc::new(recip_repo),
883            Arc::new(owner_repo),
884            Arc::new(building_repo),
885            Arc::new(meeting_repo),
886            Arc::new(unit_owner_repo),
887            Arc::new(user_repo),
888        )
889    }
890
891    /// Create a valid Convocation domain entity (meeting in 20 days, Ordinary type).
892    fn make_convocation(org_id: Uuid, building_id: Uuid, meeting_id: Uuid) -> Convocation {
893        let meeting_date = Utc::now() + Duration::days(20);
894        Convocation::new(
895            Uuid::new_v4(), // acp_id
896            org_id,
897            building_id,
898            meeting_id,
899            ConvocationType::Ordinary,
900            meeting_date,
901            "FR".to_string(),
902            Uuid::new_v4(),
903        )
904        .expect("helper should produce a valid convocation")
905    }
906
907    /// Create a valid Convocation that is already Sent (status=Sent, has recipients, etc.).
908    fn make_sent_convocation(org_id: Uuid, building_id: Uuid, meeting_id: Uuid) -> Convocation {
909        let mut conv = make_convocation(org_id, building_id, meeting_id);
910        conv.mark_sent("/tmp/conv.pdf".to_string(), 5).unwrap();
911        conv
912    }
913
914    /// Create a valid ConvocationRecipient (email already sent).
915    /// Un jeu de cas d'usage où le mandataire `proxy_owner_id` existe et porte
916    /// le rôle demandé.
917    ///
918    /// La fabrique par défaut fait répondre `None` au dépôt d'utilisateurs, ce
919    /// qui conclut toujours `Coproprietaire`. Pour éprouver l'Art. 3.87 § 7 il
920    /// faut la chaîne complète : un copropriétaire rattaché à un compte, et ce
921    /// compte portant un rôle.
922    fn make_use_cases_mandataire(
923        recip_repo: MockRecipientRepo,
924        proxy_owner_id: Uuid,
925        role: crate::domain::plateforme::user::UserRole,
926    ) -> ConvocationUseCases {
927        let utilisateur = crate::domain::plateforme::user::User::new(
928            "mandataire@example.be".to_string(),
929            "hash".to_string(),
930            "Marcel".to_string(),
931            "Devos".to_string(),
932            role,
933            Some(Uuid::new_v4()),
934        )
935        .expect("utilisateur valide");
936        let user_id = utilisateur.id;
937
938        let mut mandataire = Owner::new(
939            Uuid::new_v4(),
940            "Marcel".to_string(),
941            "Devos".to_string(),
942            "mandataire@example.be".to_string(),
943            None,
944            "12 Rue de la Loi".to_string(),
945            "Bruxelles".to_string(),
946            "1000".to_string(),
947            "Belgium".to_string(),
948        )
949        .expect("copropriétaire valide");
950        mandataire.id = proxy_owner_id;
951        mandataire.user_id = Some(user_id);
952
953        let mut owner_repo = MockOwnerRepo::new();
954        owner_repo
955            .expect_find_by_id()
956            .returning(move |_| Ok(Some(mandataire.clone())));
957
958        let mut user_repo = MockUserRepo::new();
959        user_repo
960            .expect_find_by_id()
961            .returning(move |_| Ok(Some(utilisateur.clone())));
962
963        let mut building_repo = MockBuildingRepo::new();
964        building_repo.expect_find_by_id().returning(|_| Ok(None));
965
966        ConvocationUseCases::new(
967            Arc::new(MockConvRepo::new()),
968            Arc::new(recip_repo),
969            Arc::new(owner_repo),
970            Arc::new(building_repo),
971            Arc::new(MockMeetingRepo::new()),
972            Arc::new(MockUnitOwnerRepo::new()),
973            Arc::new(user_repo),
974        )
975    }
976
977    fn make_recipient(convocation_id: Uuid, owner_id: Uuid) -> ConvocationRecipient {
978        let mut r =
979            ConvocationRecipient::new(convocation_id, owner_id, "owner@example.com".to_string())
980                .unwrap();
981        r.mark_email_sent();
982        r
983    }
984
985    // ---------------------------------------------------------------------------
986    // Test 1: Create convocation with valid legal deadline (ordinary, 20 days)
987    // ---------------------------------------------------------------------------
988    #[tokio::test]
989    async fn test_create_convocation_ordinary_valid_deadline() {
990        let org_id = Uuid::new_v4();
991        let building_id = Uuid::new_v4();
992        let meeting_id = Uuid::new_v4();
993        let meeting_date = Utc::now() + Duration::days(20);
994
995        let mut conv_repo = MockConvRepo::new();
996        conv_repo.expect_create().returning(|conv| Ok(conv.clone()));
997
998        let uc = make_use_cases(
999            conv_repo,
1000            MockRecipientRepo::new(),
1001            MockOwnerRepo::new(),
1002            MockBuildingRepo::new(),
1003            MockMeetingRepo::new(),
1004        );
1005
1006        let request = CreateConvocationRequest {
1007            building_id,
1008            meeting_id,
1009            meeting_type: ConvocationType::Ordinary,
1010            meeting_date,
1011            language: "FR".to_string(),
1012        };
1013
1014        let result = uc.create_convocation(org_id, request, Uuid::new_v4()).await;
1015
1016        assert!(result.is_ok(), "Expected Ok, got: {:?}", result.err());
1017        let resp = result.unwrap();
1018        assert_eq!(resp.status, ConvocationStatus::Draft);
1019        assert_eq!(resp.language, "FR");
1020        assert!(resp.respects_legal_deadline);
1021    }
1022
1023    // ---------------------------------------------------------------------------
1024    // Test 2: Create convocation violating legal deadline (only 5 days notice)
1025    // Art. 3.87 §3 CC requires 15 days for Ordinary
1026    // ---------------------------------------------------------------------------
1027    #[tokio::test]
1028    async fn test_create_convocation_violating_legal_deadline() {
1029        let org_id = Uuid::new_v4();
1030        let meeting_date = Utc::now() + Duration::days(5); // Only 5 days — too soon
1031
1032        let uc = make_use_cases(
1033            MockConvRepo::new(),
1034            MockRecipientRepo::new(),
1035            MockOwnerRepo::new(),
1036            MockBuildingRepo::new(),
1037            MockMeetingRepo::new(),
1038        );
1039
1040        let request = CreateConvocationRequest {
1041            building_id: Uuid::new_v4(),
1042            meeting_id: Uuid::new_v4(),
1043            meeting_type: ConvocationType::Ordinary,
1044            meeting_date,
1045            language: "FR".to_string(),
1046        };
1047
1048        let result = uc.create_convocation(org_id, request, Uuid::new_v4()).await;
1049
1050        assert!(result.is_err());
1051        let err = result.unwrap_err();
1052        assert!(
1053            err.contains("3.87"),
1054            "le refus doit citer l'article qui le fonde, reçu : {err}"
1055        );
1056    }
1057
1058    // ---------------------------------------------------------------------------
1059    // Test 3: Create extraordinary convocation with valid deadline (15 days)
1060    // Art. 3.87 §3 CC: extraordinary also requires 15 days
1061    // ---------------------------------------------------------------------------
1062    #[tokio::test]
1063    async fn test_create_convocation_extraordinary_valid_deadline() {
1064        let org_id = Uuid::new_v4();
1065        let meeting_date = Utc::now() + Duration::days(16); // 16 days — enough for extraordinary
1066
1067        let mut conv_repo = MockConvRepo::new();
1068        conv_repo.expect_create().returning(|conv| Ok(conv.clone()));
1069
1070        let uc = make_use_cases(
1071            conv_repo,
1072            MockRecipientRepo::new(),
1073            MockOwnerRepo::new(),
1074            MockBuildingRepo::new(),
1075            MockMeetingRepo::new(),
1076        );
1077
1078        let request = CreateConvocationRequest {
1079            building_id: Uuid::new_v4(),
1080            meeting_id: Uuid::new_v4(),
1081            meeting_type: ConvocationType::Extraordinary,
1082            meeting_date,
1083            language: "NL".to_string(),
1084        };
1085
1086        let result = uc.create_convocation(org_id, request, Uuid::new_v4()).await;
1087
1088        assert!(result.is_ok());
1089        let resp = result.unwrap();
1090        assert_eq!(resp.language, "NL");
1091    }
1092
1093    // ---------------------------------------------------------------------------
1094    // Test 4: Schedule convocation (Draft -> Scheduled)
1095    // ---------------------------------------------------------------------------
1096    #[tokio::test]
1097    async fn test_schedule_convocation_success() {
1098        let org_id = Uuid::new_v4();
1099        let building_id = Uuid::new_v4();
1100        let meeting_id = Uuid::new_v4();
1101        let conv = make_convocation(org_id, building_id, meeting_id);
1102        let conv_id = conv.id;
1103        let min_send_date = conv.minimum_send_date;
1104
1105        let mut conv_repo = MockConvRepo::new();
1106        let conv_clone = conv.clone();
1107        conv_repo
1108            .expect_find_by_id()
1109            .returning(move |_| Ok(Some(conv_clone.clone())));
1110        conv_repo.expect_update().returning(|conv| Ok(conv.clone()));
1111
1112        let uc = make_use_cases(
1113            conv_repo,
1114            MockRecipientRepo::new(),
1115            MockOwnerRepo::new(),
1116            MockBuildingRepo::new(),
1117            MockMeetingRepo::new(),
1118        );
1119
1120        // Schedule to send before the minimum_send_date (valid)
1121        let send_date = min_send_date - Duration::days(1);
1122        let request = ScheduleConvocationRequest { send_date };
1123
1124        let result = uc.schedule_convocation(conv_id, request).await;
1125
1126        assert!(result.is_ok(), "Expected Ok, got: {:?}", result.err());
1127        let resp = result.unwrap();
1128        assert_eq!(resp.status, ConvocationStatus::Scheduled);
1129        assert!(resp.scheduled_send_date.is_some());
1130    }
1131
1132    // ---------------------------------------------------------------------------
1133    // Test 5: Cancel convocation (Draft -> Cancelled)
1134    // ---------------------------------------------------------------------------
1135    #[tokio::test]
1136    async fn test_cancel_convocation_success() {
1137        let org_id = Uuid::new_v4();
1138        let building_id = Uuid::new_v4();
1139        let meeting_id = Uuid::new_v4();
1140        let conv = make_convocation(org_id, building_id, meeting_id);
1141        let conv_id = conv.id;
1142
1143        let mut conv_repo = MockConvRepo::new();
1144        let conv_clone = conv.clone();
1145        conv_repo
1146            .expect_find_by_id()
1147            .returning(move |_| Ok(Some(conv_clone.clone())));
1148        conv_repo.expect_update().returning(|conv| Ok(conv.clone()));
1149
1150        let uc = make_use_cases(
1151            conv_repo,
1152            MockRecipientRepo::new(),
1153            MockOwnerRepo::new(),
1154            MockBuildingRepo::new(),
1155            MockMeetingRepo::new(),
1156        );
1157
1158        let result = uc.cancel_convocation(conv_id).await;
1159
1160        assert!(result.is_ok());
1161        let resp = result.unwrap();
1162        assert_eq!(resp.status, ConvocationStatus::Cancelled);
1163    }
1164
1165    // ---------------------------------------------------------------------------
1166    // Test 6: Cancel already-cancelled convocation -> error
1167    // ---------------------------------------------------------------------------
1168    #[tokio::test]
1169    async fn test_cancel_convocation_already_cancelled_error() {
1170        let org_id = Uuid::new_v4();
1171        let building_id = Uuid::new_v4();
1172        let meeting_id = Uuid::new_v4();
1173        let mut conv = make_convocation(org_id, building_id, meeting_id);
1174        conv.cancel().unwrap(); // Already cancelled
1175        let conv_id = conv.id;
1176
1177        let mut conv_repo = MockConvRepo::new();
1178        let conv_clone = conv.clone();
1179        conv_repo
1180            .expect_find_by_id()
1181            .returning(move |_| Ok(Some(conv_clone.clone())));
1182
1183        let uc = make_use_cases(
1184            conv_repo,
1185            MockRecipientRepo::new(),
1186            MockOwnerRepo::new(),
1187            MockBuildingRepo::new(),
1188            MockMeetingRepo::new(),
1189        );
1190
1191        let result = uc.cancel_convocation(conv_id).await;
1192
1193        assert!(result.is_err());
1194        assert!(result.unwrap_err().contains("already cancelled"));
1195    }
1196
1197    // ---------------------------------------------------------------------------
1198    // Test 7: Send reminders (J-3) marks recipients as reminder_sent
1199    // ---------------------------------------------------------------------------
1200    #[tokio::test]
1201    async fn test_send_reminders_marks_recipients() {
1202        let org_id = Uuid::new_v4();
1203        let building_id = Uuid::new_v4();
1204        let meeting_id = Uuid::new_v4();
1205        let conv = make_sent_convocation(org_id, building_id, meeting_id);
1206        let conv_id = conv.id;
1207
1208        let owner1_id = Uuid::new_v4();
1209        let owner2_id = Uuid::new_v4();
1210        let r1 = make_recipient(conv_id, owner1_id);
1211        let r2 = make_recipient(conv_id, owner2_id);
1212
1213        let mut recip_repo = MockRecipientRepo::new();
1214        let r1_clone = r1.clone();
1215        let r2_clone = r2.clone();
1216        recip_repo
1217            .expect_find_needing_reminder()
1218            .returning(move |_| Ok(vec![r1_clone.clone(), r2_clone.clone()]));
1219        recip_repo.expect_update().returning(|r| Ok(r.clone()));
1220
1221        let mut conv_repo = MockConvRepo::new();
1222        let conv_clone = conv.clone();
1223        conv_repo
1224            .expect_find_by_id()
1225            .returning(move |_| Ok(Some(conv_clone.clone())));
1226        conv_repo.expect_update().returning(|conv| Ok(conv.clone()));
1227
1228        let uc = make_use_cases(
1229            conv_repo,
1230            recip_repo,
1231            MockOwnerRepo::new(),
1232            MockBuildingRepo::new(),
1233            MockMeetingRepo::new(),
1234        );
1235
1236        let result = uc.send_reminders(conv_id).await;
1237
1238        assert!(result.is_ok(), "Expected Ok, got: {:?}", result.err());
1239        let recipients = result.unwrap();
1240        assert_eq!(recipients.len(), 2);
1241        // After mark_reminder_sent, reminder_sent_at should be set
1242        assert!(recipients[0].reminder_sent_at.is_some());
1243        assert!(recipients[1].reminder_sent_at.is_some());
1244    }
1245
1246    // ---------------------------------------------------------------------------
1247    // Test 8: Track email opened updates convocation tracking counts
1248    // ---------------------------------------------------------------------------
1249    #[tokio::test]
1250    async fn test_mark_email_opened_updates_tracking() {
1251        let org_id = Uuid::new_v4();
1252        let building_id = Uuid::new_v4();
1253        let meeting_id = Uuid::new_v4();
1254        let conv = make_sent_convocation(org_id, building_id, meeting_id);
1255        let conv_id = conv.id;
1256
1257        let owner_id = Uuid::new_v4();
1258        let recipient = make_recipient(conv_id, owner_id);
1259        let recipient_id = recipient.id;
1260
1261        let mut recip_repo = MockRecipientRepo::new();
1262        let recip_clone = recipient.clone();
1263        recip_repo
1264            .expect_find_by_id()
1265            .returning(move |_| Ok(Some(recip_clone.clone())));
1266        recip_repo.expect_update().returning(|r| Ok(r.clone()));
1267        recip_repo
1268            .expect_get_tracking_summary()
1269            .returning(move |_| {
1270                Ok(RecipientTrackingSummary {
1271                    total_count: 5,
1272                    opened_count: 3,
1273                    will_attend_count: 2,
1274                    will_not_attend_count: 1,
1275                    attended_count: 0,
1276                    did_not_attend_count: 0,
1277                    pending_count: 2,
1278                    failed_email_count: 0,
1279                })
1280            });
1281
1282        let mut conv_repo = MockConvRepo::new();
1283        let conv_clone = conv.clone();
1284        conv_repo
1285            .expect_find_by_id()
1286            .returning(move |_| Ok(Some(conv_clone.clone())));
1287        conv_repo.expect_update().returning(|conv| Ok(conv.clone()));
1288
1289        let uc = make_use_cases(
1290            conv_repo,
1291            recip_repo,
1292            MockOwnerRepo::new(),
1293            MockBuildingRepo::new(),
1294            MockMeetingRepo::new(),
1295        );
1296
1297        let result = uc.mark_recipient_email_opened(recipient_id).await;
1298
1299        assert!(result.is_ok(), "Expected Ok, got: {:?}", result.err());
1300        let resp = result.unwrap();
1301        assert!(resp.has_opened_email);
1302    }
1303
1304    // ---------------------------------------------------------------------------
1305    // Test 9: Update attendance status (Pending -> WillAttend)
1306    // ---------------------------------------------------------------------------
1307    #[tokio::test]
1308    async fn test_update_attendance_will_attend() {
1309        let org_id = Uuid::new_v4();
1310        let building_id = Uuid::new_v4();
1311        let meeting_id = Uuid::new_v4();
1312        let conv = make_sent_convocation(org_id, building_id, meeting_id);
1313        let conv_id = conv.id;
1314
1315        let owner_id = Uuid::new_v4();
1316        let recipient = make_recipient(conv_id, owner_id);
1317        let recipient_id = recipient.id;
1318
1319        let mut recip_repo = MockRecipientRepo::new();
1320        let recip_clone = recipient.clone();
1321        recip_repo
1322            .expect_find_by_id()
1323            .returning(move |_| Ok(Some(recip_clone.clone())));
1324        recip_repo.expect_update().returning(|r| Ok(r.clone()));
1325        recip_repo
1326            .expect_get_tracking_summary()
1327            .returning(move |_| {
1328                Ok(RecipientTrackingSummary {
1329                    total_count: 5,
1330                    opened_count: 1,
1331                    will_attend_count: 1,
1332                    will_not_attend_count: 0,
1333                    attended_count: 0,
1334                    did_not_attend_count: 0,
1335                    pending_count: 4,
1336                    failed_email_count: 0,
1337                })
1338            });
1339
1340        let mut conv_repo = MockConvRepo::new();
1341        let conv_clone = conv.clone();
1342        conv_repo
1343            .expect_find_by_id()
1344            .returning(move |_| Ok(Some(conv_clone.clone())));
1345        conv_repo.expect_update().returning(|conv| Ok(conv.clone()));
1346
1347        let uc = make_use_cases(
1348            conv_repo,
1349            recip_repo,
1350            MockOwnerRepo::new(),
1351            MockBuildingRepo::new(),
1352            MockMeetingRepo::new(),
1353        );
1354
1355        let result = uc
1356            .update_recipient_attendance(recipient_id, AttendanceStatus::WillAttend)
1357            .await;
1358
1359        assert!(result.is_ok(), "Expected Ok, got: {:?}", result.err());
1360        let resp = result.unwrap();
1361        assert_eq!(resp.attendance_status, AttendanceStatus::WillAttend);
1362        assert!(resp.has_confirmed_attendance);
1363    }
1364
1365    // ---------------------------------------------------------------------------
1366    // Test 10: Set proxy delegation (Belgian "procuration")
1367    // ---------------------------------------------------------------------------
1368    #[tokio::test]
1369    async fn test_set_proxy_delegation() {
1370        let conv_id = Uuid::new_v4();
1371        let owner_id = Uuid::new_v4();
1372        let proxy_owner_id = Uuid::new_v4();
1373        let recipient = make_recipient(conv_id, owner_id);
1374        let recipient_id = recipient.id;
1375
1376        let mut recip_repo = MockRecipientRepo::new();
1377        let recip_clone = recipient.clone();
1378        recip_repo
1379            .expect_find_by_id()
1380            .returning(move |_| Ok(Some(recip_clone.clone())));
1381        recip_repo.expect_update().returning(|r| Ok(r.clone()));
1382
1383        let uc = make_use_cases_mandataire(
1384            recip_repo,
1385            proxy_owner_id,
1386            crate::domain::plateforme::user::UserRole::Owner,
1387        );
1388
1389        let result = uc.set_recipient_proxy(recipient_id, proxy_owner_id).await;
1390
1391        assert!(result.is_ok(), "Expected Ok, got: {:?}", result.err());
1392        let resp = result.unwrap();
1393        assert_eq!(resp.proxy_owner_id, Some(proxy_owner_id));
1394    }
1395
1396    // ---------------------------------------------------------------------------
1397    // Test 11: Set proxy to self -> error ("Cannot delegate to self")
1398    // ---------------------------------------------------------------------------
1399    #[tokio::test]
1400    async fn test_set_proxy_to_self_error() {
1401        let conv_id = Uuid::new_v4();
1402        let owner_id = Uuid::new_v4();
1403        let recipient = make_recipient(conv_id, owner_id);
1404        let recipient_id = recipient.id;
1405        let self_owner_id = recipient.owner_id;
1406
1407        let mut recip_repo = MockRecipientRepo::new();
1408        let recip_clone = recipient.clone();
1409        recip_repo
1410            .expect_find_by_id()
1411            .returning(move |_| Ok(Some(recip_clone.clone())));
1412
1413        let uc = make_use_cases_mandataire(
1414            recip_repo,
1415            self_owner_id,
1416            crate::domain::plateforme::user::UserRole::Owner,
1417        );
1418
1419        let result = uc.set_recipient_proxy(recipient_id, self_owner_id).await;
1420
1421        assert!(result.is_err());
1422        assert!(result.unwrap_err().contains("Cannot delegate to self"));
1423    }
1424
1425    // ---------------------------------------------------------------------------
1426    // Art. 3.87 § 7 — le syndic ne peut intervenir comme mandataire
1427    // ---------------------------------------------------------------------------
1428
1429    /// Le mandat au profit du syndic est refusé à l'ENREGISTREMENT.
1430    ///
1431    /// Avant le 2026-09-07, il était accepté ici et refusé au dépouillement par
1432    /// `procurations.rs`. Le copropriétaire ne l'apprenait donc qu'une fois la
1433    /// séance tenue — quand le vote est à reprendre et que les décisions prises
1434    /// sont attaquables (#829).
1435    #[tokio::test]
1436    async fn le_syndic_ne_peut_pas_etre_mandataire() {
1437        let conv_id = Uuid::new_v4();
1438        let owner_id = Uuid::new_v4();
1439        let syndic_owner_id = Uuid::new_v4();
1440        let recipient = make_recipient(conv_id, owner_id);
1441        let recipient_id = recipient.id;
1442
1443        let mut recip_repo = MockRecipientRepo::new();
1444        let recip_clone = recipient.clone();
1445        recip_repo
1446            .expect_find_by_id()
1447            .returning(move |_| Ok(Some(recip_clone.clone())));
1448        // Aucune attente d'`update` : le refus doit intervenir AVANT l'écriture.
1449        // Si le mock est appelé, mockall échoue — c'est le contrôle qu'on veut.
1450
1451        let uc = make_use_cases_mandataire(
1452            recip_repo,
1453            syndic_owner_id,
1454            crate::domain::plateforme::user::UserRole::Syndic,
1455        );
1456
1457        let result = uc.set_recipient_proxy(recipient_id, syndic_owner_id).await;
1458
1459        let erreur = result.expect_err("le mandat au syndic doit être refusé");
1460        assert!(
1461            erreur.contains("3.87"),
1462            "le message doit citer l'article qui fonde le refus, reçu : {erreur}"
1463        );
1464    }
1465
1466    /// Le syndic reste copropriétaire, et un copropriétaire ordinaire reste un
1467    /// mandataire valable. Sans ce cas, le test précédent passerait aussi avec
1468    /// une règle qui refuserait TOUTE procuration.
1469    #[tokio::test]
1470    async fn un_coproprietaire_ordinaire_reste_un_mandataire_valable() {
1471        let conv_id = Uuid::new_v4();
1472        let owner_id = Uuid::new_v4();
1473        let proxy_owner_id = Uuid::new_v4();
1474        let recipient = make_recipient(conv_id, owner_id);
1475        let recipient_id = recipient.id;
1476
1477        let mut recip_repo = MockRecipientRepo::new();
1478        let recip_clone = recipient.clone();
1479        recip_repo
1480            .expect_find_by_id()
1481            .returning(move |_| Ok(Some(recip_clone.clone())));
1482        recip_repo.expect_update().returning(|r| Ok(r.clone()));
1483
1484        let uc = make_use_cases_mandataire(
1485            recip_repo,
1486            proxy_owner_id,
1487            crate::domain::plateforme::user::UserRole::Owner,
1488        );
1489
1490        let result = uc.set_recipient_proxy(recipient_id, proxy_owner_id).await;
1491
1492        assert!(result.is_ok(), "reçu : {:?}", result.err());
1493    }
1494
1495    // ---------------------------------------------------------------------------
1496    // list_eligible_recipients — écran de sélection des destinataires
1497    // (#780 verrou 1, #784). `make_use_cases` fabrique un `unit_owner_repo`
1498    // aux identifiants aléatoires non réutilisables ici : ces tests
1499    // construisent `ConvocationUseCases` directement pour maîtriser la
1500    // correspondance entre détenteurs et fiches copropriétaire.
1501    // ---------------------------------------------------------------------------
1502
1503    fn make_owner(id: Uuid, first_name: &str, last_name: &str, email: &str) -> Owner {
1504        Owner {
1505            id,
1506            organization_id: Uuid::new_v4(),
1507            user_id: None,
1508            first_name: first_name.to_string(),
1509            last_name: last_name.to_string(),
1510            email: email.to_string(),
1511            phone: None,
1512            address: "1 Rue du Test".to_string(),
1513            city: "Bruxelles".to_string(),
1514            postal_code: "1000".to_string(),
1515            country: "Belgium".to_string(),
1516            created_at: Utc::now(),
1517            updated_at: Utc::now(),
1518        }
1519    }
1520
1521    /// @happy — deux lots détenus par deux copropriétaires distincts (l'un
1522    /// possédant un lot supplémentaire) rendent deux destinataires, triés par
1523    /// nom, sans doublon.
1524    #[tokio::test]
1525    async fn happy_list_eligible_recipients_dedup_et_trie_par_nom() {
1526        let building_id = Uuid::new_v4();
1527        let zoe_id = Uuid::new_v4();
1528        let adam_id = Uuid::new_v4();
1529
1530        let mut unit_owner_repo = MockUnitOwnerRepo::new();
1531        unit_owner_repo
1532            .expect_find_active_by_building()
1533            .returning(move |_| {
1534                Ok(vec![
1535                    (Uuid::new_v4(), zoe_id, rust_decimal::Decimal::from(500)),
1536                    (Uuid::new_v4(), adam_id, rust_decimal::Decimal::from(300)),
1537                    // Même copropriétaire, second lot : ne doit compter qu'une fois.
1538                    (Uuid::new_v4(), zoe_id, rust_decimal::Decimal::from(200)),
1539                ])
1540            });
1541
1542        let mut owner_repo = MockOwnerRepo::new();
1543        owner_repo.expect_find_by_id().returning(move |id| {
1544            if id == zoe_id {
1545                Ok(Some(make_owner(zoe_id, "Zoé", "Dupont", "zoe@example.be")))
1546            } else if id == adam_id {
1547                Ok(Some(make_owner(
1548                    adam_id,
1549                    "Adam",
1550                    "Peeters",
1551                    "adam@example.be",
1552                )))
1553            } else {
1554                Ok(None)
1555            }
1556        });
1557
1558        let uc = ConvocationUseCases::new(
1559            Arc::new(MockConvRepo::new()),
1560            Arc::new(MockRecipientRepo::new()),
1561            Arc::new(owner_repo),
1562            Arc::new(MockBuildingRepo::new()),
1563            Arc::new(MockMeetingRepo::new()),
1564            Arc::new(unit_owner_repo),
1565            Arc::new(MockUserRepo::new()),
1566        );
1567
1568        let result = uc.list_eligible_recipients(building_id).await;
1569
1570        assert!(result.is_ok(), "reçu : {:?}", result.err());
1571        let destinataires = result.unwrap();
1572        assert_eq!(destinataires.len(), 2, "Zoé ne doit compter qu'une fois");
1573        assert_eq!(destinataires[0].full_name, "Adam Peeters");
1574        assert_eq!(destinataires[1].full_name, "Zoé Dupont");
1575        assert_eq!(destinataires[1].email, "zoe@example.be");
1576    }
1577
1578    /// @edge — un immeuble sans aucun lot attribué rend une liste vide, pas
1579    /// une erreur : c'est un état légitime (immeuble neuf, lots pas encore
1580    /// attribués), pas une panne.
1581    #[tokio::test]
1582    async fn edge_list_eligible_recipients_immeuble_sans_lots_rend_liste_vide() {
1583        let mut unit_owner_repo = MockUnitOwnerRepo::new();
1584        unit_owner_repo
1585            .expect_find_active_by_building()
1586            .returning(|_| Ok(vec![]));
1587
1588        let uc = ConvocationUseCases::new(
1589            Arc::new(MockConvRepo::new()),
1590            Arc::new(MockRecipientRepo::new()),
1591            Arc::new(MockOwnerRepo::new()),
1592            Arc::new(MockBuildingRepo::new()),
1593            Arc::new(MockMeetingRepo::new()),
1594            Arc::new(unit_owner_repo),
1595            Arc::new(MockUserRepo::new()),
1596        );
1597
1598        let result = uc.list_eligible_recipients(Uuid::new_v4()).await;
1599
1600        assert!(result.is_ok(), "reçu : {:?}", result.err());
1601        assert!(result.unwrap().is_empty());
1602    }
1603
1604    /// @negative — un détenteur actif dont la fiche copropriétaire a disparu
1605    /// (incohérence de données) doit produire une erreur typée et nommée,
1606    /// jamais un panic ni un destinataire fantôme silencieusement ignoré.
1607    #[tokio::test]
1608    async fn negative_list_eligible_recipients_fiche_coproprietaire_introuvable_est_une_erreur() {
1609        let owner_id = Uuid::new_v4();
1610        let mut unit_owner_repo = MockUnitOwnerRepo::new();
1611        unit_owner_repo
1612            .expect_find_active_by_building()
1613            .returning(move |_| {
1614                Ok(vec![(
1615                    Uuid::new_v4(),
1616                    owner_id,
1617                    rust_decimal::Decimal::from(1000),
1618                )])
1619            });
1620
1621        let mut owner_repo = MockOwnerRepo::new();
1622        owner_repo.expect_find_by_id().returning(|_| Ok(None));
1623
1624        let uc = ConvocationUseCases::new(
1625            Arc::new(MockConvRepo::new()),
1626            Arc::new(MockRecipientRepo::new()),
1627            Arc::new(owner_repo),
1628            Arc::new(MockBuildingRepo::new()),
1629            Arc::new(MockMeetingRepo::new()),
1630            Arc::new(unit_owner_repo),
1631            Arc::new(MockUserRepo::new()),
1632        );
1633
1634        let result = uc.list_eligible_recipients(Uuid::new_v4()).await;
1635
1636        assert!(result.is_err());
1637        assert!(matches!(result.unwrap_err(), AppError::NotFound(_)));
1638    }
1639
1640    /// @negative — une sélection explicitement vide (`Some(vec![])`, ce que
1641    /// rend l'écran de sélection quand le syndic décoche tout le monde) doit
1642    /// être refusée EXPLICITEMENT, pas silencieusement remplacée par « tout
1643    /// le monde par défaut ». Confondre les deux ignorerait un renoncement
1644    /// délibéré (#780, DoD @negative).
1645    #[tokio::test]
1646    async fn negative_send_convocation_avec_selection_explicitement_vide_est_refuse() {
1647        let uc = make_use_cases(
1648            MockConvRepo::new(),
1649            MockRecipientRepo::new(),
1650            MockOwnerRepo::new(),
1651            MockBuildingRepo::new(),
1652            MockMeetingRepo::new(),
1653        );
1654
1655        let result = uc
1656            .send_convocation(
1657                Uuid::new_v4(),
1658                SendConvocationRequest {
1659                    recipient_owner_ids: Some(vec![]),
1660                },
1661            )
1662            .await;
1663
1664        assert!(result.is_err());
1665        let erreur = result.unwrap_err();
1666        assert!(
1667            erreur.contains("destinataire"),
1668            "le refus doit nommer ce qui manque, reçu : {erreur}"
1669        );
1670    }
1671}