Skip to main content

koprogo_api/application/use_cases/
resolution_use_cases.rs

1use crate::application::error::AppError;
2use crate::application::ports::{
3    MeetingRepository, ResolutionRepository, UnitOwnerRepository, UnitRepository, VoteRepository,
4};
5use crate::domain::entities::{
6    assert_vote_auth_sufficient, assert_voting_right_active, MajorityType, Resolution,
7    ResolutionStatus, ResolutionType, Vote, VoteAuthMethod, VoteChoice,
8};
9use std::sync::Arc;
10use uuid::Uuid;
11
12pub struct ResolutionUseCases {
13    resolution_repository: Arc<dyn ResolutionRepository>,
14    vote_repository: Arc<dyn VoteRepository>,
15    meeting_repository: Arc<dyn MeetingRepository>,
16    // Story H17 — résolution du droit de vote du lot (Art. 3.87 §1).
17    unit_owner_repository: Arc<dyn UnitOwnerRepository>,
18    // #850 — la quotité du lot, lue sur l'acte de base plutôt que déclarée.
19    unit_repository: Arc<dyn UnitRepository>,
20}
21
22impl ResolutionUseCases {
23    pub fn new(
24        resolution_repository: Arc<dyn ResolutionRepository>,
25        vote_repository: Arc<dyn VoteRepository>,
26        meeting_repository: Arc<dyn MeetingRepository>,
27        unit_owner_repository: Arc<dyn UnitOwnerRepository>,
28        unit_repository: Arc<dyn UnitRepository>,
29    ) -> Self {
30        Self {
31            resolution_repository,
32            vote_repository,
33            meeting_repository,
34            unit_owner_repository,
35            unit_repository,
36        }
37    }
38
39    /// Create a new resolution for a meeting
40    /// Enforces quorum validation per Art. 3.87 §5 CC before allowing resolution creation.
41    /// Issue #310: Validates agenda_item_index if provided (Art. 3.87 CC - only agenda items can be voted on)
42    pub async fn create_resolution(
43        &self,
44        meeting_id: Uuid,
45        title: String,
46        description: String,
47        resolution_type: ResolutionType,
48        majority_required: MajorityType,
49        agenda_item_index: Option<usize>,
50    ) -> Result<Resolution, String> {
51        // Fetch the meeting and check quorum (Art. 3.87 §5 CC)
52        let meeting = self
53            .meeting_repository
54            .find_by_id(meeting_id)
55            .await?
56            .ok_or_else(|| format!("Meeting not found: {}", meeting_id))?;
57
58        // Enforce quorum validation before allowing resolution creation
59        meeting.check_quorum_for_voting()?;
60
61        // Issue #310: If agenda_item_index provided, validate it exists and is non-empty
62        if let Some(index) = agenda_item_index {
63            if index >= meeting.agenda.len() {
64                return Err(
65                    "Resolution must correspond to a valid agenda item (Art. 3.87 CC)".to_string(),
66                );
67            }
68            let agenda_item = &meeting.agenda[index];
69            if agenda_item.trim().is_empty() {
70                return Err("Agenda item cannot be empty (Art. 3.87 CC)".to_string());
71            }
72        }
73
74        let resolution = Resolution::new(
75            meeting_id,
76            title,
77            description,
78            resolution_type,
79            majority_required,
80            agenda_item_index,
81        )?;
82
83        self.resolution_repository.create(&resolution).await
84    }
85
86    /// Get a resolution by ID
87    pub async fn get_resolution(&self, id: Uuid) -> Result<Option<Resolution>, String> {
88        let resolution = self.resolution_repository.find_by_id(id).await?;
89        match resolution {
90            Some(r) => Ok(Some(self.plafonner_pour_lecture(r).await)),
91            None => Ok(None),
92        }
93    }
94
95    /// Applique le plafond de l'Art. 3.87 § 7 al. 4 au décompte **avant de le
96    /// rendre**, sans rien persister.
97    ///
98    /// **Pourquoi c'est nécessaire.** Les compteurs de la résolution
99    /// accumulent les voix **brutes** à chaque bulletin (`record_vote_pour`
100    /// et consorts). Seul `close_voting` les recalculait plafonnés. Toute
101    /// lecture antérieure à la clôture rendait donc un décompte légalement
102    /// faux — et c'est celui-là que le syndic lit à l'écran pendant la
103    /// séance, puisque `pour_percentage()` en dérive. Constaté en recette le
104    /// 2026-09-04 (R3-1, R3-2) : Alice, 550 ‰ sur 1000, apparaissait à 550 au
105    /// lieu des 450 que lui laisse la somme des autres présents.
106    ///
107    /// **Pourquoi en mémoire seulement.** La clôture reste le seul moment où
108    /// le décompte officiel est arrêté et écrit. Persister ici ferait deux
109    /// écritures concurrentes du même fait, et une lecture pourrait figer un
110    /// décompte alors que des bulletins restent à venir.
111    ///
112    /// **Pourquoi réutiliser et ne pas recalculer.** `plafonner_les_voix` et
113    /// `repartir_le_plafond` sont les fonctions que `close_voting` emploie.
114    /// Une seconde implémentation du plafond, même correcte le jour où on
115    /// l'écrit, finirait par diverger de celle qui fait foi.
116    ///
117    /// En cas d'échec de lecture des bulletins, la résolution est rendue
118    /// telle quelle plutôt que de faire échouer la requête : une page qui
119    /// s'affiche vaut mieux qu'un 500. Le décompte reste alors brut, ce qui
120    /// est l'ancien comportement, jamais pire.
121    async fn plafonner_pour_lecture(&self, mut resolution: Resolution) -> Resolution {
122        let Ok(votes) = self
123            .vote_repository
124            .find_by_resolution_id(resolution.id)
125            .await
126        else {
127            return resolution;
128        };
129        if votes.is_empty() {
130            return resolution;
131        }
132        let decompte = crate::domain::copropriete::plafonner_les_voix(&votes);
133        if decompte.ecarts().is_empty() {
134            return resolution;
135        }
136        let poids_retenus = crate::domain::copropriete::repartir_le_plafond(&votes, &decompte);
137        resolution.recompter_avec(&votes, &poids_retenus);
138        resolution
139    }
140
141    /// Get all resolutions for a meeting
142    pub async fn get_meeting_resolutions(
143        &self,
144        meeting_id: Uuid,
145    ) -> Result<Vec<Resolution>, String> {
146        let resolutions = self
147            .resolution_repository
148            .find_by_meeting_id(meeting_id)
149            .await?;
150        // Même plafonnement qu'en lecture unitaire : la liste d'une séance est
151        // ce que le syndic a sous les yeux pendant qu'il préside.
152        let mut plafonnees = Vec::with_capacity(resolutions.len());
153        for r in resolutions {
154            plafonnees.push(self.plafonner_pour_lecture(r).await);
155        }
156        Ok(plafonnees)
157    }
158
159    /// Get resolutions by status
160    pub async fn get_resolutions_by_status(
161        &self,
162        status: ResolutionStatus,
163    ) -> Result<Vec<Resolution>, String> {
164        self.resolution_repository.find_by_status(status).await
165    }
166
167    /// Update a resolution (only allowed if status is Pending).
168    ///
169    /// Refuse aussi toute résolution générée d'office (Story 4.6, #581) :
170    /// immuable quel que soit son statut — le syndic qu'elle évalue ne peut
171    /// pas la réécrire.
172    pub async fn update_resolution(&self, resolution: &Resolution) -> Result<Resolution, AppError> {
173        if resolution.is_auto_generated() {
174            return Err(AppError::ResolutionAutoNotRemovable);
175        }
176        if resolution.status != ResolutionStatus::Pending {
177            return Err(AppError::Validation(
178                "Cannot update a resolution that is not pending".to_string(),
179            ));
180        }
181
182        self.resolution_repository
183            .update(resolution)
184            .await
185            .map_err(AppError::from)
186    }
187
188    /// Delete a resolution (only allowed if no votes have been cast).
189    ///
190    /// Refuse aussi toute résolution générée d'office (Story 4.6, #581) —
191    /// c'est le cœur de la story : le point évalue le syndic, il ne peut donc
192    /// pas le retirer de l'ordre du jour.
193    pub async fn delete_resolution(&self, id: Uuid) -> Result<bool, AppError> {
194        let resolution = self
195            .resolution_repository
196            .find_by_id(id)
197            .await
198            .map_err(AppError::from)?
199            .ok_or_else(|| AppError::NotFound(format!("resolution {}", id)))?;
200
201        if resolution.is_auto_generated() {
202            return Err(AppError::ResolutionAutoNotRemovable);
203        }
204
205        // Check if any votes exist
206        let votes = self
207            .vote_repository
208            .find_by_resolution_id(id)
209            .await
210            .map_err(AppError::from)?;
211        if !votes.is_empty() {
212            return Err(AppError::Validation(
213                "Cannot delete a resolution with existing votes".to_string(),
214            ));
215        }
216
217        self.resolution_repository
218            .delete(id)
219            .await
220            .map_err(AppError::from)
221    }
222
223    /// Cast a vote on a resolution
224    ///
225    /// `caller_owner_id` est le copropriétaire résolu depuis l'utilisateur
226    /// authentifié (#850) — jamais un identifiant repris tel quel du corps de
227    /// la requête. `owner_id` et `proxy_owner_id`, eux, restent ce que le
228    /// client envoie ; c'est précisément ce que ce cas d'usage vérifie
229    /// désormais contre `caller_owner_id`, plutôt que de les transmettre les
230    /// yeux fermés.
231    ///
232    /// `auth_method` (Story 4.2, #48) est validé contre la modalité de l'AG :
233    /// absent → 422 (`VOTE_AUTH_METHOD_REQUIRED`), insuffisant pour un mode
234    /// remote/hybrid → 403 (`VOTE_AUTH_INSUFFICIENT`).
235    #[allow(clippy::too_many_arguments)]
236    pub async fn cast_vote(
237        &self,
238        resolution_id: Uuid,
239        owner_id: Uuid,
240        unit_id: Uuid,
241        vote_choice: VoteChoice,
242        proxy_owner_id: Option<Uuid>,
243        auth_method: Option<VoteAuthMethod>,
244        caller_owner_id: Uuid,
245    ) -> Result<Vote, String> {
246        // Check if resolution exists and is pending
247        let resolution = self
248            .resolution_repository
249            .find_by_id(resolution_id)
250            .await?
251            .ok_or_else(|| "Resolution not found".to_string())?;
252
253        if resolution.status != ResolutionStatus::Pending {
254            return Err("Cannot vote on a resolution that is not pending".to_string());
255        }
256
257        // Story H10 — Art. 3.87 §5 CC : un vote n'est valable que si le quorum de
258        // l'AG est atteint (sauf 2e convocation). Gate défense-en-profondeur sur
259        // le chemin VOTE (en plus du gate à la création de résolution, l.47) :
260        // résout la réunion via `resolution.meeting_id` puis `check_quorum_for_voting`.
261        let meeting = self
262            .meeting_repository
263            .find_by_id(resolution.meeting_id)
264            .await?
265            .ok_or_else(|| format!("Meeting not found: {}", resolution.meeting_id))?;
266        meeting.check_quorum_for_voting()?;
267
268        // Art. 3.87 § 2 CC — une décision portant sur un point ABSENT de l'ordre
269        // du jour est nulle. On refuse donc le VOTE, pas la rédaction.
270        //
271        // La validation existait déjà à la création, et elle est correcte :
272        //
273        //     if let Some(index) = agenda_item_index { … }
274        //
275        // Mais `None` traverse le `if let` sans rien déclencher. La garde ne
276        // s'appliquait qu'à ceux qui se déclarent, alors que la nullité, elle,
277        // n'est pas optionnelle. Une résolution rattachée à aucun point était
278        // créée, votée et clôturée normalement — exactement le cas que
279        // l'article annule.
280        //
281        // POURQUOI AU VOTE ET NON À LA CRÉATION (#840, voie 2 sur 3).
282        //
283        // L'article annule la DÉCISION, pas la proposition. Refuser à la
284        // création casserait `meeting_handlers.rs:633` et
285        // `meeting_minutes_exporter.rs:407`, qui construisent légitimement des
286        // résolutions sans index, et exigerait une migration des lignes dont la
287        // colonne est NULL. Refuser au vote suit le texte au plus près et ne
288        // casse ni appelant ni donnée : une résolution hors ordre du jour peut
289        // exister en brouillon, elle ne peut simplement pas être mise aux voix.
290        //
291        // Les deux autres voies restent ouvertes — champ obligatoire avec
292        // migration, ou refus à la clôture — si l'usage montre que celle-ci ne
293        // suffit pas.
294        let Some(index) = resolution.agenda_item_index else {
295            return Err(
296                // Le remède nommé doit être atteignable : aucune route HTTP ne
297                // rattache une résolution existante à un point (`update_resolution`
298                // n'est pas exposé), donc la seule voie est de la recréer. Dire
299                // « rattachez-la » enverrait le syndic chercher un bouton absent.
300                "Cette résolution n'est rattachée à aucun point de l'ordre du jour : \
301                 la mettre aux voix produirait une décision nulle (Art. 3.87 § 2 CC). \
302                 Inscrivez le point à l'ordre du jour, puis recréez la résolution \
303                 en la rattachant à ce point."
304                    .to_string(),
305            );
306        };
307        // Le point doit exister ET porter un intitulé : un point vide ne
308        // renseigne personne sur ce qui est mis aux voix.
309        match meeting.agenda.get(index) {
310            None => {
311                return Err(format!(
312                    "Le point d'ordre du jour n° {index} n'existe pas dans cette \
313                     assemblée, qui en compte {} : la décision serait nulle \
314                     (Art. 3.87 § 2 CC).",
315                    meeting.agenda.len()
316                ))
317            }
318            Some(intitule) if intitule.trim().is_empty() => {
319                return Err(format!(
320                    "Le point d'ordre du jour n° {index} est vide : une décision ne \
321                     peut pas porter sur un point qui n'énonce rien \
322                     (Art. 3.87 § 2 CC)."
323                ))
324            }
325            Some(_) => {}
326        }
327
328        // Story H17 — Art. 3.87 §1 CC : un lot démembré (usufruit/nue-propriété,
329        // emphytéose, superficie) ou en indivision a son droit de vote SUSPENDU
330        // tant qu'un représentant unique n'est pas désigné. Gate → rejet
331        // `VOTING_RIGHT_SUSPENDED`. Un lot suspendu ne peut donc pas voter : il
332        // est de facto exclu du décompte/quorum (cohérence H9). Rétro-compat :
333        // un lot sans titularité qualifiée reste `Active` (cf. domaine).
334        let holders = self
335            .unit_owner_repository
336            .find_voting_holders_by_unit(unit_id)
337            .await?;
338        assert_voting_right_active(unit_id, &holders)?;
339
340        // #850 — QUI vote pour ce lot doit se vérifier, pas se déclarer.
341        //
342        // `owner_id` arrivait du corps de la requête et partait tel quel dans
343        // `Vote::new`, sans qu'aucun rapprochement ne soit fait avec
344        // l'appelant. Tout utilisateur authentifié de l'organisation pouvait
345        // donc voter au nom d'un autre copropriétaire — et, en omettant
346        // `proxy_owner_id`, échapper du même coup à la limite de trois
347        // procurations de l'Art. 3.87 § 7, qui n'était vérifiée que si
348        // `proxy_owner_id` était fourni (`validate_proxy_limit` plus bas).
349        //
350        // Le vote n'est légitime que dans deux cas :
351        //   - l'appelant vote pour lui-même (`owner_id == caller_owner_id`),
352        //     sans procuration ;
353        //   - l'appelant se déclare mandataire de `owner_id`
354        //     (`proxy_owner_id == Some(caller_owner_id)`) — et passe alors,
355        //     plus bas, par `validate_proxy_limit` comme n'importe quel
356        //     mandataire.
357        //
358        // Se taire sur la procuration n'est plus une manière de l'esquiver :
359        // ne pas être `owner_id` ET ne pas se déclarer son mandataire est un
360        // refus, quel que soit l'état de ses procurations — précisément le
361        // contournement que #850 décrit.
362        match proxy_owner_id {
363            Some(declare) if declare != caller_owner_id => {
364                return Err(format!(
365                    "FORBIDDEN: {caller_owner_id} ne peut se déclarer mandataire \
366                     que de lui-même, pas de {declare}."
367                ));
368            }
369            None if owner_id != caller_owner_id => {
370                return Err(format!(
371                    "FORBIDDEN: {caller_owner_id} ne peut pas voter au nom du \
372                     copropriétaire {owner_id} sans se déclarer son mandataire \
373                     (Art. 3.87 § 7 CC)."
374                ));
375            }
376            Some(_) | None => {}
377        }
378
379        // Le lot doit réellement appartenir à `owner_id` — soi-même dans un
380        // vote direct, ou le mandant représenté dans un vote par procuration.
381        // Rien ne le vérifiait : n'importe quel `owner_id` de l'organisation
382        // passait, pourvu que le lot ne soit pas suspendu (gate ci-dessus).
383        if self
384            .unit_owner_repository
385            .find_active_by_unit_and_owner(unit_id, owner_id)
386            .await?
387            .is_none()
388        {
389            return Err(format!(
390                "FORBIDDEN: le copropriétaire {owner_id} ne détient pas le lot \
391                 {unit_id} : il ne peut pas voter pour lui (Art. 3.87 § 1er CC)."
392            ));
393        }
394
395        // Check if unit has already voted
396        if self
397            .vote_repository
398            .has_voted(resolution_id, unit_id)
399            .await?
400        {
401            return Err("This unit has already voted on this resolution".to_string());
402        }
403
404        // #850 — la puissance de vote se LIT, elle ne se déclare pas.
405        //
406        // Elle arrivait jusqu'ici depuis le corps de la requête et partait
407        // telle quelle dans `Vote::new`. Or la voix d'un lot est sa quotité
408        // dans l'acte de base (Art. 3.87 § 2 et § 8 CC) : un copropriétaire ne
409        // choisit pas son poids, pas plus qu'il ne choisit ses tantièmes.
410        //
411        // POURQUOI `unit.quota` ET NON LA QUOTE-PART DE CHARGE.
412        //
413        // `find_active_quota_shares_by_building` rend
414        // `(unit.quota / total) × ownership_percentage`, qui est la formule de
415        // RÉPARTITION des charges (Art. 3.84). Elle ne convient pas au vote :
416        // un lot en indivision vote par son représentant unique (Art. 3.87 §1,
417        // vérifié plus haut) et vote la quotité ENTIÈRE du lot, pas la fraction
418        // détenue par ce représentant. Diviser la voix entre indivisaires
419        // reviendrait à affaiblir un lot parce qu'il est détenu à plusieurs.
420        let unit = self
421            .unit_repository
422            .find_by_id(unit_id)
423            .await?
424            .ok_or_else(|| format!("Lot introuvable : {unit_id}"))?;
425
426        // Un lot d'un AUTRE immeuble n'a rien à voter ici. Rien ne l'empêchait :
427        // la route acceptait n'importe quel `unit_id`, et le seul contrôle
428        // portait sur la titularité de ce lot, pas sur son rattachement à
429        // l'assemblée.
430        if unit.building_id != meeting.building_id {
431            return Err(format!(
432                "Le lot {unit_id} n'appartient pas à l'immeuble de cette \
433                 assemblée : il ne peut pas prendre part à ce vote."
434            ));
435        }
436
437        let voting_power = unit.quota;
438
439        // Art. 3.87 §7 CC — un mandataire ne peut détenir plus de 3 procurations
440        // Exception : si le total des voix de ses procurations < 10% du total général
441        if let Some(proxy_id) = proxy_owner_id {
442            self.validate_proxy_limit(resolution_id, proxy_id, voting_power)
443                .await?;
444        }
445
446        // Story 4.2 (#48) — Art. 3.87 §1er, §4 CC : un vote distant/hybride
447        // exige une méthode qui engage réellement le votant (itsme/eID), ou
448        // une procuration en bonne et due forme ; une méthode qui ne fait
449        // qu'affirmer une présence ne suffit pas à distance. `auth_method`
450        // est par ailleurs toujours requis : un vote sans méthode déclarée
451        // n'est pas exploitable en cas de contestation.
452        let auth_method =
453            assert_vote_auth_sufficient(meeting.mode, auth_method, proxy_owner_id.is_some())?;
454
455        // Create and save the vote
456        let vote = Vote::new_with_auth_method(
457            resolution_id,
458            owner_id,
459            unit_id,
460            vote_choice.clone(),
461            voting_power,
462            proxy_owner_id,
463            auth_method,
464        )?;
465
466        let created_vote = self.vote_repository.create(&vote).await?;
467
468        // Update vote counts on the resolution
469        self.update_resolution_vote_counts(resolution_id).await?;
470
471        Ok(created_vote)
472    }
473
474    /// Change a vote (if allowed by business rules)
475    pub async fn change_vote(&self, vote_id: Uuid, new_choice: VoteChoice) -> Result<Vote, String> {
476        let mut vote = self
477            .vote_repository
478            .find_by_id(vote_id)
479            .await?
480            .ok_or_else(|| "Vote not found".to_string())?;
481
482        // Check if resolution is still pending
483        let resolution = self
484            .resolution_repository
485            .find_by_id(vote.resolution_id)
486            .await?
487            .ok_or_else(|| "Resolution not found".to_string())?;
488
489        if resolution.status != ResolutionStatus::Pending {
490            return Err("Cannot change vote on a closed resolution".to_string());
491        }
492
493        // Update the vote
494        vote.change_vote(new_choice)?;
495        let updated_vote = self.vote_repository.update(&vote).await?;
496
497        // Recalculate vote counts
498        self.update_resolution_vote_counts(vote.resolution_id)
499            .await?;
500
501        Ok(updated_vote)
502    }
503
504    /// Get all votes for a resolution
505    pub async fn get_resolution_votes(&self, resolution_id: Uuid) -> Result<Vec<Vote>, String> {
506        self.vote_repository
507            .find_by_resolution_id(resolution_id)
508            .await
509    }
510
511    /// Get all votes by an owner
512    pub async fn get_owner_votes(&self, owner_id: Uuid) -> Result<Vec<Vote>, String> {
513        self.vote_repository.find_by_owner_id(owner_id).await
514    }
515
516    /// Close voting on a resolution and calculate final result
517    pub async fn close_voting(
518        &self,
519        resolution_id: Uuid,
520        total_voting_power: rust_decimal::Decimal,
521    ) -> Result<Resolution, String> {
522        let mut resolution = self
523            .resolution_repository
524            .find_by_id(resolution_id)
525            .await?
526            .ok_or_else(|| "Resolution not found".to_string())?;
527
528        if resolution.status != ResolutionStatus::Pending {
529            return Err("Resolution voting is already closed".to_string());
530        }
531
532        // Art. 3.87 § 7 — les plafonds de procuration se vérifient à la
533        // CLÔTURE, pas à chaque vote : ils portent sur l'ensemble des voix
534        // exprimées, et le dernier vote enregistré peut faire basculer une
535        // séance qui était licite jusque-là.
536        //
537        // Le refus est bloquant. Une assemblée tenue en violation de ces
538        // plafonds est attaquable, et ce sont ses décisions — travaux,
539        // budgets, mandats — qui tombent avec elle. Mieux vaut refuser de
540        // clore que proclamer un résultat annulable.
541        let votes = self
542            .vote_repository
543            .find_by_resolution_id(resolution_id)
544            .await?;
545        crate::domain::copropriete::verifier_procurations(&votes, total_voting_power, None)
546            .map_err(|refus| refus.to_string())?;
547
548        // Art. 3.87 § 9 — le prestataire ne délibère pas sur sa propre
549        // mission. Même moment, même raison : c'est l'ensemble des bulletins
550        // qu'il faut regarder, pas le dernier déposé.
551        crate::domain::copropriete::verifier_conflit_dinterets(
552            &votes,
553            resolution.prestataire_de_la_mission,
554        )
555        .map_err(|conflit| conflit.to_string())?;
556
557        // Art. 3.87 § 7 al. 4 — le plafonnement des voix.
558        //
559        // Arbitrage humain du 2026-09-04 : le texte interdit de prendre part au
560        // vote POUR un nombre de voix supérieur à la somme des autres, il ne
561        // frappe pas la séance de nullité. On ramène donc le majoritaire au
562        // poids des autres et on délibère sur ce décompte corrigé, au lieu de
563        // refuser de clore — ce qui rendait ingouvernable toute copropriété où
564        // un seul détient la majorité.
565        let decompte = crate::domain::copropriete::plafonner_les_voix(&votes);
566        let voix_plafonnees = if decompte.ecarts().is_empty() {
567            None
568        } else {
569            let poids_retenus = crate::domain::copropriete::repartir_le_plafond(&votes, &decompte);
570            resolution.recompter_avec(&votes, &poids_retenus);
571            // Le décompte corrigé doit être PERSISTÉ, pas seulement servir au
572            // calcul : sans cela la base garderait les voix brutes tandis que
573            // le statut refléterait les voix plafonnées. Le procès-verbal
574            // afficherait alors un décompte que son propre résultat contredit,
575            // ce qui est indéfendable si la décision est attaquée.
576            self.resolution_repository
577                .update_vote_counts(
578                    resolution_id,
579                    resolution.vote_count_pour,
580                    resolution.vote_count_contre,
581                    resolution.vote_count_abstention,
582                    resolution.total_voting_power_pour,
583                    resolution.total_voting_power_contre,
584                    resolution.total_voting_power_abstention,
585                )
586                .await?;
587            Some(serde_json::json!(decompte
588                .ecarts()
589                .iter()
590                .map(|e| serde_json::json!({
591                    "votant": e.votant,
592                    "voix_brutes": e.voix_brutes,
593                    "voix_retenues": e.voix_retenues,
594                }))
595                .collect::<Vec<_>>()))
596        };
597
598        // Calculate final result
599        resolution.close_voting(total_voting_power)?;
600
601        // Update resolution with final status
602        self.resolution_repository
603            .close_voting(resolution_id, resolution.status.clone(), voix_plafonnees)
604            .await?;
605
606        // Fetch updated resolution
607        self.resolution_repository
608            .find_by_id(resolution_id)
609            .await?
610            .ok_or_else(|| "Resolution not found after closing".to_string())
611    }
612
613    /// Get vote summary for a meeting (all resolutions with their results)
614    pub async fn get_meeting_vote_summary(
615        &self,
616        meeting_id: Uuid,
617    ) -> Result<Vec<Resolution>, String> {
618        self.resolution_repository
619            .get_meeting_vote_summary(meeting_id)
620            .await
621    }
622
623    /// Helper: Update vote counts for a resolution based on actual votes
624    async fn update_resolution_vote_counts(&self, resolution_id: Uuid) -> Result<(), String> {
625        // Get vote counts
626        let (pour_count, contre_count, abstention_count) = self
627            .vote_repository
628            .count_by_resolution_and_choice(resolution_id)
629            .await?;
630
631        // Get voting power totals
632        let (pour_power, contre_power, abstention_power) = self
633            .vote_repository
634            .sum_voting_power_by_resolution(resolution_id)
635            .await?;
636
637        // Update resolution
638        self.resolution_repository
639            .update_vote_counts(
640                resolution_id,
641                pour_count,
642                contre_count,
643                abstention_count,
644                pour_power,
645                contre_power,
646                abstention_power,
647            )
648            .await
649    }
650
651    /// Check if a unit has voted on a resolution
652    pub async fn has_unit_voted(&self, resolution_id: Uuid, unit_id: Uuid) -> Result<bool, String> {
653        self.vote_repository.has_voted(resolution_id, unit_id).await
654    }
655
656    /// Valide la limite de procurations par mandataire (Art. 3.87 §7 CC).
657    ///
658    /// Règle: un mandataire ne peut détenir plus de 3 procurations.
659    /// Exception: si le total des voix représentées < 10% du total général,
660    /// la limite de 3 ne s'applique pas.
661    ///
662    /// Le `new_voting_power` est le pouvoir de vote de la nouvelle procuration
663    /// envisagée (utilisé pour le calcul de l'exception 10%).
664    async fn validate_proxy_limit(
665        &self,
666        resolution_id: Uuid,
667        proxy_owner_id: Uuid,
668        new_voting_power: rust_decimal::Decimal,
669    ) -> Result<(), String> {
670        let (existing_count, existing_power) = self
671            .vote_repository
672            .count_proxy_votes_for_mandataire(resolution_id, proxy_owner_id)
673            .await?;
674
675        // Total du pouvoir de vote représenté après ajout de la nouvelle procuration
676        let total_proxy_power = existing_power + new_voting_power;
677
678        // Exception 10% : si le total des procurations < 10% du total AG, pas de limite
679        // On récupère le total de la résolution
680        let resolution = self
681            .resolution_repository
682            .find_by_id(resolution_id)
683            .await?
684            .ok_or_else(|| "Resolution not found".to_string())?;
685
686        // total_voting_power_* contient les votes déjà exprimés
687        // Pour l'exception, on compare la puissance des procurations au total millièmes
688        // En pratique, le syndic passe le total_quotas du meeting — on utilise une heuristique
689        let total_all_votes = resolution.total_voting_power_pour
690            + resolution.total_voting_power_contre
691            + resolution.total_voting_power_abstention
692            + new_voting_power; // inclure ce qu'on est en train d'ajouter
693
694        // Exception: si total procurations < 10% du total général → pas de limite
695        if total_all_votes > rust_decimal::Decimal::ZERO
696            && (total_proxy_power / total_all_votes) < rust_decimal_macros::dec!(0.10)
697        {
698            return Ok(()); // Exception 10% s'applique
699        }
700
701        // Règle générale: max 3 procurations
702        if existing_count >= 3 {
703            return Err(format!(
704                "Le mandataire détient déjà {} procurations. Maximum autorisé : 3 (Art. 3.87 §7 CC). \
705                 Exception 10% non applicable (procurations représentent >{:.1}% des votes).",
706                existing_count,
707                if total_all_votes > rust_decimal::Decimal::ZERO {
708                    (total_proxy_power / total_all_votes) * rust_decimal_macros::dec!(100)
709                } else {
710                    rust_decimal::Decimal::ZERO
711                }
712            ));
713        }
714
715        Ok(())
716    }
717
718    /// Get vote statistics for a resolution
719    pub async fn get_vote_statistics(&self, resolution_id: Uuid) -> Result<VoteStatistics, String> {
720        let resolution = self
721            .resolution_repository
722            .find_by_id(resolution_id)
723            .await?
724            .ok_or_else(|| "Resolution not found".to_string())?;
725
726        Ok(VoteStatistics {
727            total_votes: resolution.total_votes(),
728            vote_count_pour: resolution.vote_count_pour,
729            vote_count_contre: resolution.vote_count_contre,
730            vote_count_abstention: resolution.vote_count_abstention,
731            total_voting_power_pour: resolution.total_voting_power_pour,
732            total_voting_power_contre: resolution.total_voting_power_contre,
733            total_voting_power_abstention: resolution.total_voting_power_abstention,
734            pour_percentage: resolution.pour_percentage(),
735            contre_percentage: resolution.contre_percentage(),
736            abstention_percentage: resolution.abstention_percentage(),
737            status: resolution.status,
738        })
739    }
740}
741
742/// Vote statistics for a resolution
743#[derive(Debug, Clone)]
744pub struct VoteStatistics {
745    pub total_votes: i32,
746    pub vote_count_pour: i32,
747    pub vote_count_contre: i32,
748    pub vote_count_abstention: i32,
749    pub total_voting_power_pour: rust_decimal::Decimal,
750    pub total_voting_power_contre: rust_decimal::Decimal,
751    pub total_voting_power_abstention: rust_decimal::Decimal,
752    pub pour_percentage: f64,
753    pub contre_percentage: f64,
754    pub abstention_percentage: f64,
755    pub status: ResolutionStatus,
756}
757
758#[cfg(test)]
759mod tests {
760    // `status_code()` vient du trait `ResponseError` : sans l'importer, la
761    // méthode n'existe pas sur `AppError` au point d'appel. Deux branches
762    // ont ajouté des tests qui l'emploient, aucune n'a apporté l'import.
763    use super::*;
764    use crate::application::dto::{PageRequest, UnitFilters};
765    use crate::application::ports::{
766        MeetingRepository, ResolutionRepository, UnitOwnerRepository, UnitRepository,
767        VoteRepository,
768    };
769    use crate::domain::entities::{
770        LotHolder, Meeting, MeetingMode, MeetingType, OwnershipType, Unit, UnitOwner, UnitType,
771        VoteChoice,
772    };
773    use actix_web::ResponseError;
774    use async_trait::async_trait;
775    use chrono::Utc;
776    use rust_decimal_macros::dec;
777    use std::collections::HashMap;
778    use std::sync::Mutex;
779
780    // Story H17 — mock UnitOwnerRepository : `find_voting_holders_by_unit`
781    // configurable par lot (défaut vide → `voting_right_status` Active, donc
782    // les tests existants ne changent pas de comportement).
783    struct MockUnitOwnerRepository {
784        holders: Mutex<HashMap<Uuid, Vec<LotHolder>>>,
785        /// #850 — paires (lot, owner) dont la détention est refusée par
786        /// `find_active_by_unit_and_owner`. Vide par défaut : la détention
787        /// est acceptée pour toute paire, comme `holders` vide laisse le
788        /// droit de vote actif. Les tests antérieurs à #850 n'ont jamais
789        /// enregistré qui détient quoi ; seuls ceux qui éprouvent précisément
790        /// l'usurpation de lot la peuplent via `refuse_detention`.
791        detentions_refusees: Mutex<std::collections::HashSet<(Uuid, Uuid)>>,
792    }
793
794    impl MockUnitOwnerRepository {
795        fn new() -> Self {
796            Self {
797                holders: Mutex::new(HashMap::new()),
798                detentions_refusees: Mutex::new(std::collections::HashSet::new()),
799            }
800        }
801
802        fn with_holders(unit_id: Uuid, holders: Vec<LotHolder>) -> Self {
803            let m = Self::new();
804            m.holders.lock().unwrap().insert(unit_id, holders);
805            m
806        }
807
808        /// Marque `(unit_id, owner_id)` comme une détention refusée : le lot
809        /// n'appartient pas à cet owner selon le dépôt.
810        fn refuse_detention(&self, unit_id: Uuid, owner_id: Uuid) {
811            self.detentions_refusees
812                .lock()
813                .unwrap()
814                .insert((unit_id, owner_id));
815        }
816    }
817
818    #[async_trait]
819    impl UnitOwnerRepository for MockUnitOwnerRepository {
820        async fn create(&self, unit_owner: &UnitOwner) -> Result<UnitOwner, String> {
821            Ok(unit_owner.clone())
822        }
823        async fn find_by_id(&self, _id: Uuid) -> Result<Option<UnitOwner>, String> {
824            Ok(None)
825        }
826        async fn find_current_owners_by_unit(
827            &self,
828            _unit_id: Uuid,
829        ) -> Result<Vec<UnitOwner>, String> {
830            Ok(vec![])
831        }
832        async fn find_current_units_by_owner(
833            &self,
834            _owner_id: Uuid,
835        ) -> Result<Vec<UnitOwner>, String> {
836            Ok(vec![])
837        }
838        async fn find_all_owners_by_unit(&self, _unit_id: Uuid) -> Result<Vec<UnitOwner>, String> {
839            Ok(vec![])
840        }
841        async fn find_all_units_by_owner(&self, _owner_id: Uuid) -> Result<Vec<UnitOwner>, String> {
842            Ok(vec![])
843        }
844        async fn update(&self, unit_owner: &UnitOwner) -> Result<UnitOwner, String> {
845            Ok(unit_owner.clone())
846        }
847        async fn delete(&self, _id: Uuid) -> Result<(), String> {
848            Ok(())
849        }
850        async fn has_active_owners(&self, _unit_id: Uuid) -> Result<bool, String> {
851            Ok(true)
852        }
853        async fn get_total_ownership_percentage(
854            &self,
855            _unit_id: Uuid,
856        ) -> Result<rust_decimal::Decimal, String> {
857            Ok(rust_decimal::Decimal::ONE)
858        }
859        async fn find_active_by_unit_and_owner(
860            &self,
861            unit_id: Uuid,
862            owner_id: Uuid,
863        ) -> Result<Option<UnitOwner>, String> {
864            if self
865                .detentions_refusees
866                .lock()
867                .unwrap()
868                .contains(&(unit_id, owner_id))
869            {
870                return Ok(None);
871            }
872            Ok(Some(
873                UnitOwner::new(unit_id, owner_id, rust_decimal::Decimal::ONE, true)
874                    .expect("détention de test"),
875            ))
876        }
877        async fn find_active_by_building(
878            &self,
879            _building_id: Uuid,
880        ) -> Result<Vec<(Uuid, Uuid, rust_decimal::Decimal)>, String> {
881            Ok(vec![])
882        }
883
884        /// Même source que ci-dessus dans les tests : les fixtures posent
885        /// directement des quotes-parts déjà résolues.
886        async fn find_active_quota_shares_by_building(
887            &self,
888            _building_id: Uuid,
889        ) -> Result<Vec<(Uuid, Uuid, rust_decimal::Decimal)>, String> {
890            Ok(vec![])
891        }
892        async fn find_voting_holders_by_unit(
893            &self,
894            unit_id: Uuid,
895        ) -> Result<Vec<LotHolder>, String> {
896            Ok(self
897                .holders
898                .lock()
899                .unwrap()
900                .get(&unit_id)
901                .cloned()
902                .unwrap_or_default())
903        }
904
905        async fn is_voting_representative(&self, _unit_owner_id: Uuid) -> Result<bool, String> {
906            Ok(false)
907        }
908
909        async fn set_voting_representative(&self, _unit_owner_id: Uuid) -> Result<(), String> {
910            Ok(())
911        }
912    }
913
914    // Mock repositories for testing
915    /// Les lots, pour que la quotité se LISE (#850).
916    ///
917    /// `defaut` sert les tests écrits avant #850 : ils votent sur des lots
918    /// qu'ils n'ont jamais créés, parce que la puissance de vote arrivait
919    /// alors dans l'appel. Plutôt que de réécrire quarante scénarios, le mock
920    /// fabrique un lot à la demande, rattaché à l'immeuble qu'on lui indique.
921    ///
922    /// Les tests qui éprouvent PRÉCISÉMENT la quotité, eux, enregistrent leur
923    /// lot explicitement.
924    struct MockUnitRepository {
925        units: Mutex<HashMap<Uuid, Unit>>,
926        defaut: Mutex<Option<(Uuid, rust_decimal::Decimal)>>,
927    }
928
929    impl MockUnitRepository {
930        fn new() -> Self {
931            Self {
932                units: Mutex::new(HashMap::new()),
933                defaut: Mutex::new(None),
934            }
935        }
936
937        fn enregistre(&self, unit: Unit) {
938            self.units.lock().unwrap().insert(unit.id, unit);
939        }
940
941        /// Pose le lot par défaut une fois l'immeuble connu.
942        ///
943        /// Le constructeur est appelé avant que le test n'ait tiré son
944        /// `building_id` ; cette méthode permet de le lui donner ensuite.
945        fn pose_defaut(&self, building_id: Uuid, quota: rust_decimal::Decimal) {
946            *self.defaut.lock().unwrap() = Some((building_id, quota));
947        }
948
949        /// Enregistre un lot d'une quotité donnée, dans l'immeuble donné.
950        fn lot(&self, unit_id: Uuid, building_id: Uuid, quota: rust_decimal::Decimal) {
951            let mut unit = Unit::new(
952                Uuid::new_v4(),
953                building_id,
954                format!("L{}", &unit_id.to_string()[..4]),
955                UnitType::Apartment,
956                Some(0),
957                50.0,
958                quota,
959            )
960            .expect("lot de test");
961            unit.id = unit_id;
962            self.enregistre(unit);
963        }
964    }
965
966    #[async_trait]
967    impl UnitRepository for MockUnitRepository {
968        async fn create(&self, unit: &Unit) -> Result<Unit, String> {
969            self.units.lock().unwrap().insert(unit.id, unit.clone());
970            Ok(unit.clone())
971        }
972
973        async fn find_by_id(&self, id: Uuid) -> Result<Option<Unit>, String> {
974            if let Some(unit) = self.units.lock().unwrap().get(&id).cloned() {
975                return Ok(Some(unit));
976            }
977            let defaut = *self.defaut.lock().unwrap();
978            Ok(defaut.map(|(building_id, quota)| {
979                let mut unit = Unit::new(
980                    Uuid::new_v4(),
981                    building_id,
982                    format!("L{}", &id.to_string()[..4]),
983                    UnitType::Apartment,
984                    Some(0),
985                    50.0,
986                    quota,
987                )
988                .expect("lot de test");
989                unit.id = id;
990                unit
991            }))
992        }
993
994        async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Unit>, String> {
995            Ok(self
996                .units
997                .lock()
998                .unwrap()
999                .values()
1000                .filter(|u| u.building_id == building_id)
1001                .cloned()
1002                .collect())
1003        }
1004
1005        async fn find_by_owner(&self, _owner_id: Uuid) -> Result<Vec<Unit>, String> {
1006            Ok(vec![])
1007        }
1008
1009        async fn find_all_paginated(
1010            &self,
1011            _page_request: &PageRequest,
1012            _filters: &UnitFilters,
1013        ) -> Result<(Vec<Unit>, i64), String> {
1014            let units: Vec<_> = self.units.lock().unwrap().values().cloned().collect();
1015            let total = units.len() as i64;
1016            Ok((units, total))
1017        }
1018
1019        async fn update(&self, unit: &Unit) -> Result<Unit, String> {
1020            self.units.lock().unwrap().insert(unit.id, unit.clone());
1021            Ok(unit.clone())
1022        }
1023
1024        async fn delete(&self, id: Uuid) -> Result<bool, String> {
1025            Ok(self.units.lock().unwrap().remove(&id).is_some())
1026        }
1027    }
1028
1029    struct MockMeetingRepository {
1030        meetings: Mutex<HashMap<Uuid, Meeting>>,
1031    }
1032
1033    impl MockMeetingRepository {
1034        fn new() -> Self {
1035            Self {
1036                meetings: Mutex::new(HashMap::new()),
1037            }
1038        }
1039    }
1040
1041    #[async_trait]
1042    impl MeetingRepository for MockMeetingRepository {
1043        async fn create(&self, meeting: &Meeting) -> Result<Meeting, String> {
1044            self.meetings
1045                .lock()
1046                .unwrap()
1047                .insert(meeting.id, meeting.clone());
1048            Ok(meeting.clone())
1049        }
1050
1051        async fn find_by_id(&self, id: Uuid) -> Result<Option<Meeting>, String> {
1052            Ok(self.meetings.lock().unwrap().get(&id).cloned())
1053        }
1054
1055        async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Meeting>, String> {
1056            Ok(self
1057                .meetings
1058                .lock()
1059                .unwrap()
1060                .values()
1061                .filter(|m| m.building_id == building_id)
1062                .cloned()
1063                .collect())
1064        }
1065
1066        async fn update(&self, meeting: &Meeting) -> Result<Meeting, String> {
1067            self.meetings
1068                .lock()
1069                .unwrap()
1070                .insert(meeting.id, meeting.clone());
1071            Ok(meeting.clone())
1072        }
1073
1074        async fn delete(&self, id: Uuid) -> Result<bool, String> {
1075            Ok(self.meetings.lock().unwrap().remove(&id).is_some())
1076        }
1077
1078        async fn find_all_paginated(
1079            &self,
1080            _page_request: &PageRequest,
1081            _organization_id: Option<Uuid>,
1082        ) -> Result<(Vec<Meeting>, i64), String> {
1083            let meetings: Vec<_> = self.meetings.lock().unwrap().values().cloned().collect();
1084            let total = meetings.len() as i64;
1085            Ok((meetings, total))
1086        }
1087    }
1088
1089    struct MockResolutionRepository {
1090        resolutions: Mutex<HashMap<Uuid, Resolution>>,
1091        /// Ce que la dernière clôture a consigné au titre de l'Art. 3.87 § 7
1092        /// al. 4. Sans ce champ, la trace du plafonnement serait invérifiable
1093        /// en test unitaire, et le seul moyen de la constater serait la base.
1094        dernier_plafonnement: Mutex<Option<serde_json::Value>>,
1095    }
1096
1097    impl MockResolutionRepository {
1098        fn new() -> Self {
1099            Self {
1100                resolutions: Mutex::new(HashMap::new()),
1101                dernier_plafonnement: Mutex::new(None),
1102            }
1103        }
1104    }
1105
1106    #[async_trait]
1107    impl ResolutionRepository for MockResolutionRepository {
1108        async fn create(&self, resolution: &Resolution) -> Result<Resolution, String> {
1109            self.resolutions
1110                .lock()
1111                .unwrap()
1112                .insert(resolution.id, resolution.clone());
1113            Ok(resolution.clone())
1114        }
1115
1116        async fn find_by_id(&self, id: Uuid) -> Result<Option<Resolution>, String> {
1117            Ok(self.resolutions.lock().unwrap().get(&id).cloned())
1118        }
1119
1120        async fn find_by_meeting_id(&self, meeting_id: Uuid) -> Result<Vec<Resolution>, String> {
1121            Ok(self
1122                .resolutions
1123                .lock()
1124                .unwrap()
1125                .values()
1126                .filter(|r| r.meeting_id == meeting_id)
1127                .cloned()
1128                .collect())
1129        }
1130
1131        async fn find_by_status(
1132            &self,
1133            status: ResolutionStatus,
1134        ) -> Result<Vec<Resolution>, String> {
1135            Ok(self
1136                .resolutions
1137                .lock()
1138                .unwrap()
1139                .values()
1140                .filter(|r| r.status == status)
1141                .cloned()
1142                .collect())
1143        }
1144
1145        async fn update(&self, resolution: &Resolution) -> Result<Resolution, String> {
1146            self.resolutions
1147                .lock()
1148                .unwrap()
1149                .insert(resolution.id, resolution.clone());
1150            Ok(resolution.clone())
1151        }
1152
1153        async fn delete(&self, id: Uuid) -> Result<bool, String> {
1154            Ok(self.resolutions.lock().unwrap().remove(&id).is_some())
1155        }
1156
1157        async fn update_vote_counts(
1158            &self,
1159            resolution_id: Uuid,
1160            vote_count_pour: i32,
1161            vote_count_contre: i32,
1162            vote_count_abstention: i32,
1163            total_voting_power_pour: rust_decimal::Decimal,
1164            total_voting_power_contre: rust_decimal::Decimal,
1165            total_voting_power_abstention: rust_decimal::Decimal,
1166        ) -> Result<(), String> {
1167            if let Some(resolution) = self.resolutions.lock().unwrap().get_mut(&resolution_id) {
1168                resolution.vote_count_pour = vote_count_pour;
1169                resolution.vote_count_contre = vote_count_contre;
1170                resolution.vote_count_abstention = vote_count_abstention;
1171                resolution.total_voting_power_pour = total_voting_power_pour;
1172                resolution.total_voting_power_contre = total_voting_power_contre;
1173                resolution.total_voting_power_abstention = total_voting_power_abstention;
1174            }
1175            Ok(())
1176        }
1177
1178        async fn close_voting(
1179            &self,
1180            resolution_id: Uuid,
1181            final_status: ResolutionStatus,
1182            voix_plafonnees: Option<serde_json::Value>,
1183        ) -> Result<(), String> {
1184            // La trace du plafonnement est conservée par le mock, sans quoi
1185            // aucun test unitaire ne pourrait vérifier qu'elle est bien écrite.
1186            *self.dernier_plafonnement.lock().unwrap() = voix_plafonnees;
1187            if let Some(resolution) = self.resolutions.lock().unwrap().get_mut(&resolution_id) {
1188                resolution.status = final_status;
1189                resolution.voted_at = Some(chrono::Utc::now());
1190            }
1191            Ok(())
1192        }
1193
1194        async fn get_meeting_vote_summary(
1195            &self,
1196            meeting_id: Uuid,
1197        ) -> Result<Vec<Resolution>, String> {
1198            self.find_by_meeting_id(meeting_id).await
1199        }
1200    }
1201
1202    struct MockVoteRepository {
1203        votes: Mutex<HashMap<Uuid, Vote>>,
1204    }
1205
1206    impl MockVoteRepository {
1207        fn new() -> Self {
1208            Self {
1209                votes: Mutex::new(HashMap::new()),
1210            }
1211        }
1212    }
1213
1214    #[async_trait]
1215    impl VoteRepository for MockVoteRepository {
1216        async fn create(&self, vote: &Vote) -> Result<Vote, String> {
1217            self.votes.lock().unwrap().insert(vote.id, vote.clone());
1218            Ok(vote.clone())
1219        }
1220
1221        async fn find_by_id(&self, id: Uuid) -> Result<Option<Vote>, String> {
1222            Ok(self.votes.lock().unwrap().get(&id).cloned())
1223        }
1224
1225        async fn find_by_resolution_id(&self, resolution_id: Uuid) -> Result<Vec<Vote>, String> {
1226            Ok(self
1227                .votes
1228                .lock()
1229                .unwrap()
1230                .values()
1231                .filter(|v| v.resolution_id == resolution_id)
1232                .cloned()
1233                .collect())
1234        }
1235
1236        async fn find_by_owner_id(&self, owner_id: Uuid) -> Result<Vec<Vote>, String> {
1237            Ok(self
1238                .votes
1239                .lock()
1240                .unwrap()
1241                .values()
1242                .filter(|v| v.owner_id == owner_id)
1243                .cloned()
1244                .collect())
1245        }
1246
1247        async fn find_by_resolution_and_unit(
1248            &self,
1249            resolution_id: Uuid,
1250            unit_id: Uuid,
1251        ) -> Result<Option<Vote>, String> {
1252            Ok(self
1253                .votes
1254                .lock()
1255                .unwrap()
1256                .values()
1257                .find(|v| v.resolution_id == resolution_id && v.unit_id == unit_id)
1258                .cloned())
1259        }
1260
1261        async fn has_voted(&self, resolution_id: Uuid, unit_id: Uuid) -> Result<bool, String> {
1262            Ok(self
1263                .find_by_resolution_and_unit(resolution_id, unit_id)
1264                .await?
1265                .is_some())
1266        }
1267
1268        async fn update(&self, vote: &Vote) -> Result<Vote, String> {
1269            self.votes.lock().unwrap().insert(vote.id, vote.clone());
1270            Ok(vote.clone())
1271        }
1272
1273        async fn delete(&self, id: Uuid) -> Result<bool, String> {
1274            Ok(self.votes.lock().unwrap().remove(&id).is_some())
1275        }
1276
1277        async fn count_by_resolution_and_choice(
1278            &self,
1279            resolution_id: Uuid,
1280        ) -> Result<(i32, i32, i32), String> {
1281            let votes = self.find_by_resolution_id(resolution_id).await?;
1282            let pour = votes
1283                .iter()
1284                .filter(|v| v.vote_choice == VoteChoice::Pour)
1285                .count() as i32;
1286            let contre = votes
1287                .iter()
1288                .filter(|v| v.vote_choice == VoteChoice::Contre)
1289                .count() as i32;
1290            let abstention = votes
1291                .iter()
1292                .filter(|v| v.vote_choice == VoteChoice::Abstention)
1293                .count() as i32;
1294            Ok((pour, contre, abstention))
1295        }
1296
1297        async fn sum_voting_power_by_resolution(
1298            &self,
1299            resolution_id: Uuid,
1300        ) -> Result<
1301            (
1302                rust_decimal::Decimal,
1303                rust_decimal::Decimal,
1304                rust_decimal::Decimal,
1305            ),
1306            String,
1307        > {
1308            let votes = self.find_by_resolution_id(resolution_id).await?;
1309            let pour: rust_decimal::Decimal = votes
1310                .iter()
1311                .filter(|v| v.vote_choice == VoteChoice::Pour)
1312                .map(|v| v.voting_power)
1313                .sum();
1314            let contre: rust_decimal::Decimal = votes
1315                .iter()
1316                .filter(|v| v.vote_choice == VoteChoice::Contre)
1317                .map(|v| v.voting_power)
1318                .sum();
1319            let abstention: rust_decimal::Decimal = votes
1320                .iter()
1321                .filter(|v| v.vote_choice == VoteChoice::Abstention)
1322                .map(|v| v.voting_power)
1323                .sum();
1324            Ok((pour, contre, abstention))
1325        }
1326
1327        async fn count_proxy_votes_for_mandataire(
1328            &self,
1329            resolution_id: Uuid,
1330            proxy_owner_id: Uuid,
1331        ) -> Result<(i64, rust_decimal::Decimal), String> {
1332            let votes = self.find_by_resolution_id(resolution_id).await?;
1333            let proxy_votes: Vec<_> = votes
1334                .iter()
1335                .filter(|v| v.proxy_owner_id == Some(proxy_owner_id))
1336                .collect();
1337            let count = proxy_votes.len() as i64;
1338            let power: rust_decimal::Decimal = proxy_votes.iter().map(|v| v.voting_power).sum();
1339            Ok((count, power))
1340        }
1341    }
1342
1343    /// Le décompte servi **avant** la clôture est déjà plafonné.
1344    ///
1345    /// Reproduction exacte de la recette du 2026-09-04 (R3-1) : Alice pèse
1346    /// 550 ‰ et vote « pour », Bob (250) et Claire (200) votent « contre ».
1347    /// L'Art. 3.87 § 7 al. 4 ramène Alice à 450, la somme des autres présents.
1348    ///
1349    /// Le testeur lisait 550 et concluait que le plafonnement n'était pas
1350    /// implémenté. Il l'était — mais seulement dans `close_voting`, que rien
1351    /// ne déclenche automatiquement. Toute la séance se déroulait donc sous
1352    /// les yeux du syndic avec un décompte légalement faux.
1353    ///
1354    /// **Ce test lit sans clôturer.** C'était précisément l'angle mort : les
1355    /// tests du domaine passaient, `close_voting` était correct, et l'API
1356    /// restait fausse. Un test qui clôture d'abord ne prouverait rien ici.
1357    #[tokio::test]
1358    async fn test_art_3_87_la_lecture_avant_cloture_est_deja_plafonnee() {
1359        let resolution_repo = Arc::new(MockResolutionRepository::new());
1360        let vote_repo = Arc::new(MockVoteRepository::new());
1361        let use_cases = ResolutionUseCases::new(
1362            resolution_repo.clone(),
1363            vote_repo.clone(),
1364            Arc::new(MockMeetingRepository::new()),
1365            Arc::new(MockUnitOwnerRepository::new()),
1366            Arc::new(MockUnitRepository::new()),
1367        );
1368
1369        let resolution = Resolution::new(
1370            Uuid::new_v4(),
1371            "RECETTE-Approbation des comptes".to_string(),
1372            "Scénario de recette du 2026-09-04".to_string(),
1373            ResolutionType::Ordinary,
1374            MajorityType::Absolute,
1375            Some(1),
1376        )
1377        .expect("résolution valide");
1378        let resolution_id = resolution.id;
1379        resolution_repo
1380            .create(&resolution)
1381            .await
1382            .expect("résolution enregistrée");
1383
1384        for (voix, choix) in [
1385            (dec!(550), VoteChoice::Pour),
1386            (dec!(250), VoteChoice::Contre),
1387            (dec!(200), VoteChoice::Contre),
1388        ] {
1389            vote_repo
1390                .create(
1391                    &Vote::new(
1392                        resolution_id,
1393                        Uuid::new_v4(),
1394                        Uuid::new_v4(),
1395                        choix,
1396                        voix,
1397                        None,
1398                    )
1399                    .expect("vote valide"),
1400                )
1401                .await
1402                .expect("vote enregistré");
1403        }
1404
1405        // AUCUNE clôture ici. C'est tout l'objet du test.
1406        let lue = use_cases
1407            .get_resolution(resolution_id)
1408            .await
1409            .expect("lecture réussie")
1410            .expect("résolution trouvée");
1411
1412        assert_eq!(
1413            lue.total_voting_power_pour,
1414            dec!(450),
1415            "Alice doit être ramenée à la somme des autres présents (250 + 200), \
1416             pas servie à ses 550 ‰ bruts"
1417        );
1418        assert_eq!(lue.total_voting_power_contre, dec!(450));
1419        assert_eq!(
1420            lue.pour_percentage(),
1421            50.0,
1422            "50 % n'est pas la majorité absolue : le pourcentage affiché doit \
1423             répondre au résultat que la clôture proclamera"
1424        );
1425        assert_eq!(
1426            lue.status,
1427            ResolutionStatus::Pending,
1428            "plafonner la lecture ne clôt pas la résolution"
1429        );
1430    }
1431
1432    /// Le plafonnement de lecture ne persiste rien.
1433    ///
1434    /// Deux lectures successives doivent rendre le même résultat. Si la
1435    /// première écrivait le décompte corrigé, la seconde replafonnerait un
1436    /// décompte déjà plafonné et le rétrécirait encore.
1437    #[tokio::test]
1438    async fn test_la_lecture_plafonnee_est_idempotente_et_ne_persiste_rien() {
1439        let resolution_repo = Arc::new(MockResolutionRepository::new());
1440        let vote_repo = Arc::new(MockVoteRepository::new());
1441        let use_cases = ResolutionUseCases::new(
1442            resolution_repo.clone(),
1443            vote_repo.clone(),
1444            Arc::new(MockMeetingRepository::new()),
1445            Arc::new(MockUnitOwnerRepository::new()),
1446            Arc::new(MockUnitRepository::new()),
1447        );
1448
1449        let resolution = Resolution::new(
1450            Uuid::new_v4(),
1451            "Lecture répétée".to_string(),
1452            "Contrôle d'idempotence".to_string(),
1453            ResolutionType::Ordinary,
1454            MajorityType::Absolute,
1455            Some(1),
1456        )
1457        .expect("résolution valide");
1458        let resolution_id = resolution.id;
1459        resolution_repo.create(&resolution).await.expect("créée");
1460
1461        for (voix, choix) in [
1462            (dec!(550), VoteChoice::Pour),
1463            (dec!(250), VoteChoice::Contre),
1464            (dec!(200), VoteChoice::Contre),
1465        ] {
1466            vote_repo
1467                .create(
1468                    &Vote::new(
1469                        resolution_id,
1470                        Uuid::new_v4(),
1471                        Uuid::new_v4(),
1472                        choix,
1473                        voix,
1474                        None,
1475                    )
1476                    .expect("vote valide"),
1477                )
1478                .await
1479                .expect("vote enregistré");
1480        }
1481
1482        let une = use_cases
1483            .get_resolution(resolution_id)
1484            .await
1485            .unwrap()
1486            .unwrap();
1487        let deux = use_cases
1488            .get_resolution(resolution_id)
1489            .await
1490            .unwrap()
1491            .unwrap();
1492        assert_eq!(une.total_voting_power_pour, deux.total_voting_power_pour);
1493        assert_eq!(deux.total_voting_power_pour, dec!(450));
1494    }
1495
1496    /// Art. 3.87 § 7 : la clôture refuse une séance où un votant pèse plus
1497    /// que tous les autres réunis : ses voix sont **ramenées** à la somme des
1498    /// leurs, et l'écart est consigné.
1499    ///
1500    /// Le contrôle est à la clôture et pas au vote, parce qu'il porte sur
1501    /// l'ensemble des voix : c'est le dernier bulletin déposé qui peut faire
1502    /// basculer une séance licite jusque-là.
1503    ///
1504    /// Anciennement `..._refuse_un_votant_plus_lourd_...`, qui attendait un
1505    /// refus de clore. Arbitrage humain du 2026-09-04, confirmé par la
1506    /// doctrine belge : l'Art. 3.87 § 7 al. 4 plafonne, il n'annule pas.
1507    #[tokio::test]
1508    async fn test_art_3_87_la_cloture_plafonne_un_votant_plus_lourd_que_tous_les_autres() {
1509        let resolution_repo = Arc::new(MockResolutionRepository::new());
1510        let vote_repo = Arc::new(MockVoteRepository::new());
1511        let use_cases = ResolutionUseCases::new(
1512            resolution_repo.clone(),
1513            vote_repo.clone(),
1514            Arc::new(MockMeetingRepository::new()),
1515            Arc::new(MockUnitOwnerRepository::new()),
1516            Arc::new(MockUnitRepository::new()),
1517        );
1518
1519        let resolution = Resolution::new(
1520            Uuid::new_v4(),
1521            "Réfection de la toiture".to_string(),
1522            "Travaux de couverture votés en AGO".to_string(),
1523            ResolutionType::Ordinary,
1524            MajorityType::Absolute,
1525            Some(1),
1526        )
1527        .expect("résolution valide");
1528        let resolution_id = resolution.id;
1529        resolution_repo
1530            .create(&resolution)
1531            .await
1532            .expect("résolution enregistrée");
1533
1534        // 600 pour un seul, 399 pour tous les autres réunis.
1535        for (voix, _) in [(dec!(600), 0), (dec!(200), 1), (dec!(199), 2)] {
1536            vote_repo
1537                .create(
1538                    &Vote::new(
1539                        resolution_id,
1540                        Uuid::new_v4(),
1541                        Uuid::new_v4(),
1542                        VoteChoice::Pour,
1543                        voix,
1544                        None,
1545                    )
1546                    .expect("vote valide"),
1547                )
1548                .await
1549                .expect("vote enregistré");
1550        }
1551
1552        // La séance se clôture : le poids d'un votant ne fait plus obstacle.
1553        use_cases
1554            .close_voting(resolution_id, dec!(1000))
1555            .await
1556            .expect("le plafonnement remplace le refus");
1557
1558        // L'écart est consigné, sans quoi le procès-verbal afficherait un
1559        // décompte que rien dans les bulletins ne permettrait de retrouver.
1560        let trace = resolution_repo
1561            .dernier_plafonnement
1562            .lock()
1563            .unwrap()
1564            .clone()
1565            .expect("un plafonnement a eu lieu, il doit être tracé");
1566        let ecarts = trace.as_array().expect("un tableau d'écarts");
1567        assert_eq!(
1568            ecarts.len(),
1569            1,
1570            "un seul votant dépassait la somme des autres"
1571        );
1572        assert_eq!(ecarts[0]["voix_brutes"], serde_json::json!(dec!(600)));
1573        assert_eq!(
1574            ecarts[0]["voix_retenues"],
1575            serde_json::json!(dec!(399)),
1576            "ramené à 200 + 199"
1577        );
1578    }
1579
1580    /// Reproduction du scénario de recette du 2026-09-04.
1581    ///
1582    /// Alice pèse 550 et vote « pour ». Bob (250) et Claire (200) votent
1583    /// « contre ». Après plafonnement, Alice est ramenée à 450 : le décompte
1584    /// devient 450 contre 450.
1585    ///
1586    /// La majorité absolue de l'Art. 3.88 § 1er exige **plus** de la moitié
1587    /// des voix exprimées. Une égalité n'est pas une majorité : la résolution
1588    /// doit être REJETÉE.
1589    #[tokio::test]
1590    async fn test_art_3_88_une_egalite_apres_plafonnement_nest_pas_une_majorite() {
1591        let resolution_repo = Arc::new(MockResolutionRepository::new());
1592        let vote_repo = Arc::new(MockVoteRepository::new());
1593        let use_cases = ResolutionUseCases::new(
1594            resolution_repo.clone(),
1595            vote_repo.clone(),
1596            Arc::new(MockMeetingRepository::new()),
1597            Arc::new(MockUnitOwnerRepository::new()),
1598            Arc::new(MockUnitRepository::new()),
1599        );
1600
1601        let resolution = Resolution::new(
1602            Uuid::new_v4(),
1603            "Approbation des comptes".to_string(),
1604            "Comptes annuels".to_string(),
1605            ResolutionType::Ordinary,
1606            MajorityType::Absolute,
1607            Some(1),
1608        )
1609        .expect("résolution valide");
1610        let resolution_id = resolution.id;
1611        resolution_repo
1612            .create(&resolution)
1613            .await
1614            .expect("enregistrée");
1615
1616        for (voix, choix) in [
1617            (dec!(550), VoteChoice::Pour),
1618            (dec!(250), VoteChoice::Contre),
1619            (dec!(200), VoteChoice::Contre),
1620        ] {
1621            vote_repo
1622                .create(
1623                    &Vote::new(
1624                        resolution_id,
1625                        Uuid::new_v4(),
1626                        Uuid::new_v4(),
1627                        choix,
1628                        voix,
1629                        None,
1630                    )
1631                    .expect("vote valide"),
1632                )
1633                .await
1634                .expect("vote enregistré");
1635        }
1636
1637        let close = use_cases
1638            .close_voting(resolution_id, dec!(1000))
1639            .await
1640            .expect("la clôture aboutit");
1641
1642        assert_eq!(
1643            close.total_voting_power_pour,
1644            dec!(450),
1645            "Alice est ramenée à la somme des autres"
1646        );
1647        assert_eq!(close.total_voting_power_contre, dec!(450));
1648        assert_eq!(
1649            close.status,
1650            ResolutionStatus::Rejected,
1651            "450 contre 450 : égalité, donc pas de majorité absolue"
1652        );
1653    }
1654
1655    /// La même séance, répartie normalement, se clôture sans obstacle.
1656    #[tokio::test]
1657    async fn test_art_3_87_une_seance_equilibree_se_cloture() {
1658        let resolution_repo = Arc::new(MockResolutionRepository::new());
1659        let vote_repo = Arc::new(MockVoteRepository::new());
1660        let use_cases = ResolutionUseCases::new(
1661            resolution_repo.clone(),
1662            vote_repo.clone(),
1663            Arc::new(MockMeetingRepository::new()),
1664            Arc::new(MockUnitOwnerRepository::new()),
1665            Arc::new(MockUnitRepository::new()),
1666        );
1667
1668        let resolution = Resolution::new(
1669            Uuid::new_v4(),
1670            "Réfection de la toiture".to_string(),
1671            "Travaux de couverture votés en AGO".to_string(),
1672            ResolutionType::Ordinary,
1673            MajorityType::Absolute,
1674            Some(1),
1675        )
1676        .expect("résolution valide");
1677        let resolution_id = resolution.id;
1678        resolution_repo
1679            .create(&resolution)
1680            .await
1681            .expect("résolution enregistrée");
1682
1683        for voix in [dec!(400), dec!(300), dec!(300)] {
1684            vote_repo
1685                .create(
1686                    &Vote::new(
1687                        resolution_id,
1688                        Uuid::new_v4(),
1689                        Uuid::new_v4(),
1690                        VoteChoice::Pour,
1691                        voix,
1692                        None,
1693                    )
1694                    .expect("vote valide"),
1695                )
1696                .await
1697                .expect("vote enregistré");
1698        }
1699
1700        assert!(use_cases
1701            .close_voting(resolution_id, dec!(1000))
1702            .await
1703            .is_ok());
1704    }
1705
1706    #[tokio::test]
1707    async fn test_create_resolution() {
1708        let resolution_repo = Arc::new(MockResolutionRepository::new());
1709        let vote_repo = Arc::new(MockVoteRepository::new());
1710        let meeting_repo = Arc::new(MockMeetingRepository::new());
1711        let use_cases = ResolutionUseCases::new(
1712            resolution_repo.clone(),
1713            vote_repo,
1714            meeting_repo.clone(),
1715            Arc::new(MockUnitOwnerRepository::new()),
1716            Arc::new(MockUnitRepository::new()),
1717        );
1718
1719        let org_id = Uuid::new_v4();
1720        let building_id = Uuid::new_v4();
1721        let meeting_id = Uuid::new_v4();
1722
1723        // Create a meeting with quorum reached
1724        let mut meeting = Meeting::new(
1725            Uuid::new_v4(), // acp_id
1726            org_id,
1727            building_id,
1728            MeetingType::Ordinary,
1729            "AGO 2024".to_string(),
1730            None,
1731            Utc::now() + chrono::Duration::days(30),
1732            "Salle des fêtes".to_string(),
1733        )
1734        .unwrap();
1735        meeting.id = meeting_id;
1736        // Validate quorum (600/1000 = 60% > 50%)
1737        meeting
1738            .validate_quorum(
1739                rust_decimal_macros::dec!(600),
1740                rust_decimal_macros::dec!(1000),
1741            )
1742            .unwrap();
1743        // Un point d'ordre du jour, sans quoi aucune résolution ne peut être
1744        // mise aux voix : Art. 3.87 § 2 CC annule une décision portant sur un
1745        // point absent. Ces tests votaient jusqu'ici sur des résolutions
1746        // rattachées à rien — le cas exact que l'article annule.
1747        meeting
1748            .add_agenda_item("Approbation des comptes".to_string())
1749            .unwrap();
1750        meeting_repo.create(&meeting).await.unwrap();
1751
1752        let result = use_cases
1753            .create_resolution(
1754                meeting_id,
1755                "Test Resolution".to_string(),
1756                "Description".to_string(),
1757                ResolutionType::Ordinary,
1758                MajorityType::Absolute,
1759                Some(0),
1760            )
1761            .await;
1762
1763        assert!(result.is_ok());
1764        let resolution = result.unwrap();
1765        assert_eq!(resolution.title, "Test Resolution");
1766        assert_eq!(resolution.status, ResolutionStatus::Pending);
1767    }
1768
1769    #[tokio::test]
1770    async fn test_create_resolution_fails_without_quorum() {
1771        let resolution_repo = Arc::new(MockResolutionRepository::new());
1772        let vote_repo = Arc::new(MockVoteRepository::new());
1773        let meeting_repo = Arc::new(MockMeetingRepository::new());
1774        let use_cases = ResolutionUseCases::new(
1775            resolution_repo.clone(),
1776            vote_repo,
1777            meeting_repo.clone(),
1778            Arc::new(MockUnitOwnerRepository::new()),
1779            Arc::new(MockUnitRepository::new()),
1780        );
1781
1782        let org_id = Uuid::new_v4();
1783        let building_id = Uuid::new_v4();
1784        let meeting_id = Uuid::new_v4();
1785
1786        // Create a meeting with quorum NOT reached
1787        let mut meeting = Meeting::new(
1788            Uuid::new_v4(), // acp_id
1789            org_id,
1790            building_id,
1791            MeetingType::Ordinary,
1792            "AGO 2024".to_string(),
1793            None,
1794            Utc::now() + chrono::Duration::days(30),
1795            "Salle des fêtes".to_string(),
1796        )
1797        .unwrap();
1798        meeting.id = meeting_id;
1799        // Validate quorum (400/1000 = 40% < 50%) — quorum NOT reached
1800        meeting
1801            .validate_quorum(
1802                rust_decimal_macros::dec!(400),
1803                rust_decimal_macros::dec!(1000),
1804            )
1805            .unwrap();
1806        // Un point d'ordre du jour, sans quoi aucune résolution ne peut être
1807        // mise aux voix : Art. 3.87 § 2 CC annule une décision portant sur un
1808        // point absent. Ces tests votaient jusqu'ici sur des résolutions
1809        // rattachées à rien — le cas exact que l'article annule.
1810        meeting
1811            .add_agenda_item("Approbation des comptes".to_string())
1812            .unwrap();
1813        meeting_repo.create(&meeting).await.unwrap();
1814
1815        let result = use_cases
1816            .create_resolution(
1817                meeting_id,
1818                "Test Resolution".to_string(),
1819                "Description".to_string(),
1820                ResolutionType::Ordinary,
1821                MajorityType::Absolute,
1822                Some(0),
1823            )
1824            .await;
1825
1826        // Should fail because quorum not reached
1827        assert!(result.is_err());
1828        assert!(result.unwrap_err().contains("second convocation"));
1829    }
1830
1831    /// #850 — Art. 3.87 § 2 et § 8 CC : la voix d'un lot est sa quotité dans
1832    /// l'acte de base. Elle ne se déclare pas.
1833    ///
1834    /// Le serveur acceptait `voting_power` depuis le corps de la requête et
1835    /// l'enregistrait sans la recalculer. Un copropriétaire pouvait donc
1836    /// choisir son propre poids dans l'assemblée.
1837    #[tokio::test]
1838    async fn security_la_puissance_de_vote_vient_du_lot_pas_de_lappelant() {
1839        let resolution_repo = Arc::new(MockResolutionRepository::new());
1840        let vote_repo = Arc::new(MockVoteRepository::new());
1841        let meeting_repo = Arc::new(MockMeetingRepository::new());
1842        let unit_repo = Arc::new(MockUnitRepository::new());
1843        let resolution =
1844            meeting_with_quorum_and_resolution(&resolution_repo, &meeting_repo, &unit_repo).await;
1845
1846        // Le lot vaut 42 millièmes à l'acte de base. L'appelant n'a plus aucun
1847        // moyen d'en proposer d'autres : le paramètre a disparu de la
1848        // signature, ce qui est plus sûr que de l'ignorer en silence.
1849        let meeting = meeting_repo
1850            .find_by_id(resolution.meeting_id)
1851            .await
1852            .unwrap()
1853            .unwrap();
1854        let unit_id = Uuid::new_v4();
1855        unit_repo.lot(unit_id, meeting.building_id, dec!(42));
1856
1857        let use_cases = ResolutionUseCases::new(
1858            resolution_repo.clone(),
1859            vote_repo.clone(),
1860            meeting_repo.clone(),
1861            Arc::new(MockUnitOwnerRepository::new()),
1862            unit_repo.clone(),
1863        );
1864
1865        let owner_id = Uuid::new_v4();
1866        let vote = use_cases
1867            .cast_vote(
1868                resolution.id,
1869                owner_id,
1870                unit_id,
1871                VoteChoice::Pour,
1872                None,
1873                Some(VoteAuthMethod::Presence),
1874                // #850 — vote pour soi-même : l'appelant EST l'owner_id.
1875                owner_id,
1876            )
1877            .await
1878            .expect("le vote doit aboutir");
1879
1880        assert_eq!(
1881            vote.voting_power,
1882            dec!(42),
1883            "la voix enregistrée doit être la quotité du lot (Art. 3.87 § 8 CC), \
1884             pas une valeur choisie par l'appelant"
1885        );
1886    }
1887
1888    /// #850 — un lot d'un AUTRE immeuble n'a rien à voter dans cette assemblée.
1889    ///
1890    /// La route acceptait n'importe quel `unit_id` : le seul contrôle portait
1891    /// sur la titularité du lot, jamais sur son rattachement à l'assemblée.
1892    /// Un lot d'un immeuble voisin pouvait donc peser sur une décision qui ne
1893    /// le concerne pas.
1894    #[tokio::test]
1895    async fn security_un_lot_dun_autre_immeuble_ne_peut_pas_voter() {
1896        let resolution_repo = Arc::new(MockResolutionRepository::new());
1897        let vote_repo = Arc::new(MockVoteRepository::new());
1898        let meeting_repo = Arc::new(MockMeetingRepository::new());
1899        let unit_repo = Arc::new(MockUnitRepository::new());
1900        let resolution =
1901            meeting_with_quorum_and_resolution(&resolution_repo, &meeting_repo, &unit_repo).await;
1902
1903        let lot_etranger = Uuid::new_v4();
1904        unit_repo.lot(lot_etranger, Uuid::new_v4(), dec!(100));
1905
1906        let use_cases = ResolutionUseCases::new(
1907            resolution_repo.clone(),
1908            vote_repo.clone(),
1909            meeting_repo.clone(),
1910            Arc::new(MockUnitOwnerRepository::new()),
1911            unit_repo.clone(),
1912        );
1913
1914        let owner_id = Uuid::new_v4();
1915        let erreur = use_cases
1916            .cast_vote(
1917                resolution.id,
1918                owner_id,
1919                lot_etranger,
1920                VoteChoice::Pour,
1921                None,
1922                Some(VoteAuthMethod::Presence),
1923                owner_id,
1924            )
1925            .await
1926            .expect_err("un lot d'un autre immeuble ne peut pas voter ici");
1927
1928        assert!(
1929            erreur.contains("n'appartient pas à l'immeuble"),
1930            "le refus doit dire POURQUOI, got: {erreur}"
1931        );
1932    }
1933
1934    /// #850 — un lot qui n'existe pas ne vote pas.
1935    ///
1936    /// Avant, la puissance de vote arrivant dans l'appel, rien n'obligeait le
1937    /// lot à exister : un `unit_id` inventé produisait un vote valide, compté
1938    /// dans la majorité.
1939    #[tokio::test]
1940    async fn negative_un_lot_inexistant_ne_peut_pas_voter() {
1941        let resolution_repo = Arc::new(MockResolutionRepository::new());
1942        let vote_repo = Arc::new(MockVoteRepository::new());
1943        let meeting_repo = Arc::new(MockMeetingRepository::new());
1944        // Sans lot par défaut : le dépôt ne connaît aucun lot.
1945        let unit_repo = Arc::new(MockUnitRepository::new());
1946        let resolution =
1947            meeting_with_quorum_and_resolution(&resolution_repo, &meeting_repo, &unit_repo).await;
1948        *unit_repo.defaut.lock().unwrap() = None;
1949
1950        let use_cases = ResolutionUseCases::new(
1951            resolution_repo.clone(),
1952            vote_repo.clone(),
1953            meeting_repo.clone(),
1954            Arc::new(MockUnitOwnerRepository::new()),
1955            unit_repo.clone(),
1956        );
1957
1958        let owner_id = Uuid::new_v4();
1959        let erreur = use_cases
1960            .cast_vote(
1961                resolution.id,
1962                owner_id,
1963                Uuid::new_v4(),
1964                VoteChoice::Pour,
1965                None,
1966                Some(VoteAuthMethod::Presence),
1967                owner_id,
1968            )
1969            .await
1970            .expect_err("un lot inexistant ne peut pas voter");
1971
1972        assert!(erreur.contains("Lot introuvable"), "got: {erreur}");
1973    }
1974
1975    #[tokio::test]
1976    async fn test_cast_vote_updates_counts() {
1977        let resolution_repo = Arc::new(MockResolutionRepository::new());
1978        let vote_repo = Arc::new(MockVoteRepository::new());
1979        let meeting_repo = Arc::new(MockMeetingRepository::new());
1980        let unit_repo = Arc::new(MockUnitRepository::new());
1981        let use_cases = ResolutionUseCases::new(
1982            resolution_repo.clone(),
1983            vote_repo.clone(),
1984            meeting_repo.clone(),
1985            Arc::new(MockUnitOwnerRepository::new()),
1986            unit_repo.clone(),
1987        );
1988
1989        // Create a resolution
1990        let org_id = Uuid::new_v4();
1991        let building_id = Uuid::new_v4();
1992        // #850 — chaque lot voté existe désormais, et porte sa quotité.
1993        unit_repo.pose_defaut(building_id, dec!(100));
1994        let meeting_id = Uuid::new_v4();
1995
1996        // Create a meeting with quorum reached
1997        let mut meeting = Meeting::new(
1998            Uuid::new_v4(), // acp_id
1999            org_id,
2000            building_id,
2001            MeetingType::Ordinary,
2002            "AGO 2024".to_string(),
2003            None,
2004            Utc::now() + chrono::Duration::days(30),
2005            "Salle des fêtes".to_string(),
2006        )
2007        .unwrap();
2008        meeting.id = meeting_id;
2009        meeting
2010            .validate_quorum(
2011                rust_decimal_macros::dec!(600),
2012                rust_decimal_macros::dec!(1000),
2013            )
2014            .unwrap();
2015        // Un point d'ordre du jour, sans quoi aucune résolution ne peut être
2016        // mise aux voix : Art. 3.87 § 2 CC annule une décision portant sur un
2017        // point absent. Ces tests votaient jusqu'ici sur des résolutions
2018        // rattachées à rien — le cas exact que l'article annule.
2019        meeting
2020            .add_agenda_item("Approbation des comptes".to_string())
2021            .unwrap();
2022        meeting_repo.create(&meeting).await.unwrap();
2023
2024        let resolution = use_cases
2025            .create_resolution(
2026                meeting_id,
2027                "Test Resolution".to_string(),
2028                "Description".to_string(),
2029                ResolutionType::Ordinary,
2030                MajorityType::Absolute,
2031                Some(0),
2032            )
2033            .await
2034            .unwrap();
2035
2036        // Cast a vote
2037        let owner_id = Uuid::new_v4();
2038        let unit_id = Uuid::new_v4();
2039        let result = use_cases
2040            .cast_vote(
2041                resolution.id,
2042                owner_id,
2043                unit_id,
2044                VoteChoice::Pour,
2045                None,
2046                Some(VoteAuthMethod::Presence),
2047                owner_id,
2048            )
2049            .await;
2050
2051        assert!(result.is_ok());
2052
2053        // Check that vote counts were updated
2054        let updated_resolution = use_cases
2055            .get_resolution(resolution.id)
2056            .await
2057            .unwrap()
2058            .unwrap();
2059        assert_eq!(updated_resolution.vote_count_pour, 1);
2060        assert_eq!(
2061            updated_resolution.total_voting_power_pour,
2062            rust_decimal_macros::dec!(100)
2063        );
2064    }
2065
2066    #[tokio::test]
2067    async fn test_cannot_vote_twice() {
2068        let resolution_repo = Arc::new(MockResolutionRepository::new());
2069        let vote_repo = Arc::new(MockVoteRepository::new());
2070        let meeting_repo = Arc::new(MockMeetingRepository::new());
2071        let unit_repo = Arc::new(MockUnitRepository::new());
2072        let use_cases = ResolutionUseCases::new(
2073            resolution_repo.clone(),
2074            vote_repo,
2075            meeting_repo.clone(),
2076            Arc::new(MockUnitOwnerRepository::new()),
2077            unit_repo.clone(),
2078        );
2079
2080        // Create resolution
2081        let org_id = Uuid::new_v4();
2082        let building_id = Uuid::new_v4();
2083        // #850 — chaque lot voté existe désormais, et porte sa quotité.
2084        unit_repo.pose_defaut(building_id, dec!(100));
2085        let meeting_id = Uuid::new_v4();
2086
2087        // Create a meeting with quorum reached
2088        let mut meeting = Meeting::new(
2089            Uuid::new_v4(), // acp_id
2090            org_id,
2091            building_id,
2092            MeetingType::Ordinary,
2093            "AGO 2024".to_string(),
2094            None,
2095            Utc::now() + chrono::Duration::days(30),
2096            "Salle des fêtes".to_string(),
2097        )
2098        .unwrap();
2099        meeting.id = meeting_id;
2100        meeting
2101            .validate_quorum(
2102                rust_decimal_macros::dec!(600),
2103                rust_decimal_macros::dec!(1000),
2104            )
2105            .unwrap();
2106        // Un point d'ordre du jour, sans quoi aucune résolution ne peut être
2107        // mise aux voix : Art. 3.87 § 2 CC annule une décision portant sur un
2108        // point absent. Ces tests votaient jusqu'ici sur des résolutions
2109        // rattachées à rien — le cas exact que l'article annule.
2110        meeting
2111            .add_agenda_item("Approbation des comptes".to_string())
2112            .unwrap();
2113        meeting_repo.create(&meeting).await.unwrap();
2114
2115        let resolution = use_cases
2116            .create_resolution(
2117                meeting_id,
2118                "Test".to_string(),
2119                "Desc".to_string(),
2120                ResolutionType::Ordinary,
2121                MajorityType::Absolute,
2122                Some(0),
2123            )
2124            .await
2125            .unwrap();
2126
2127        let owner_id = Uuid::new_v4();
2128        let unit_id = Uuid::new_v4();
2129
2130        // First vote succeeds
2131        let result1 = use_cases
2132            .cast_vote(
2133                resolution.id,
2134                owner_id,
2135                unit_id,
2136                VoteChoice::Pour,
2137                None,
2138                Some(VoteAuthMethod::Presence),
2139                owner_id,
2140            )
2141            .await;
2142        assert!(result1.is_ok());
2143
2144        // Second vote from same unit fails
2145        let result2 = use_cases
2146            .cast_vote(
2147                resolution.id,
2148                owner_id,
2149                unit_id,
2150                VoteChoice::Contre,
2151                None,
2152                Some(VoteAuthMethod::Presence),
2153                owner_id,
2154            )
2155            .await;
2156        assert!(result2.is_err());
2157        assert!(result2.unwrap_err().contains("already voted"));
2158    }
2159
2160    /// Art. 3.87 §7 CC — un mandataire ne peut pas détenir plus de 3 procurations
2161    #[tokio::test]
2162    async fn test_proxy_limit_max_3_enforced() {
2163        let resolution_repo = Arc::new(MockResolutionRepository::new());
2164        let vote_repo = Arc::new(MockVoteRepository::new());
2165        let meeting_repo = Arc::new(MockMeetingRepository::new());
2166        let unit_repo = Arc::new(MockUnitRepository::new());
2167        let use_cases = ResolutionUseCases::new(
2168            resolution_repo.clone(),
2169            vote_repo.clone(),
2170            meeting_repo.clone(),
2171            Arc::new(MockUnitOwnerRepository::new()),
2172            unit_repo.clone(),
2173        );
2174
2175        let org_id = Uuid::new_v4();
2176        let building_id = Uuid::new_v4();
2177        // #850 — chaque lot voté existe désormais, et porte sa quotité.
2178        unit_repo.pose_defaut(building_id, dec!(100));
2179        let meeting_id = Uuid::new_v4();
2180
2181        // Create a meeting with quorum reached
2182        let mut meeting = Meeting::new(
2183            Uuid::new_v4(), // acp_id
2184            org_id,
2185            building_id,
2186            MeetingType::Ordinary,
2187            "AGO 2024".to_string(),
2188            None,
2189            Utc::now() + chrono::Duration::days(30),
2190            "Salle des fêtes".to_string(),
2191        )
2192        .unwrap();
2193        meeting.id = meeting_id;
2194        meeting
2195            .validate_quorum(
2196                rust_decimal_macros::dec!(600),
2197                rust_decimal_macros::dec!(1000),
2198            )
2199            .unwrap();
2200        // Un point d'ordre du jour, sans quoi aucune résolution ne peut être
2201        // mise aux voix : Art. 3.87 § 2 CC annule une décision portant sur un
2202        // point absent. Ces tests votaient jusqu'ici sur des résolutions
2203        // rattachées à rien — le cas exact que l'article annule.
2204        meeting
2205            .add_agenda_item("Approbation des comptes".to_string())
2206            .unwrap();
2207        meeting_repo.create(&meeting).await.unwrap();
2208
2209        let resolution = use_cases
2210            .create_resolution(
2211                meeting_id,
2212                "Test procurations".to_string(),
2213                "Description".to_string(),
2214                ResolutionType::Ordinary,
2215                MajorityType::Absolute,
2216                Some(0),
2217            )
2218            .await
2219            .unwrap();
2220
2221        let mandataire_id = Uuid::new_v4();
2222
2223        // Cast 3 proxy votes for the same mandataire (should all succeed)
2224        for i in 0..3 {
2225            let owner_id = Uuid::new_v4();
2226            let unit_id = Uuid::new_v4();
2227            let result = use_cases
2228                .cast_vote(
2229                    resolution.id,
2230                    owner_id,
2231                    unit_id,
2232                    VoteChoice::Pour, // 100 millièmes chacun = 300 total
2233                    Some(mandataire_id),
2234                    Some(VoteAuthMethod::Proxy),
2235                    // #850 — le mandataire déclaré EST l'appelant.
2236                    mandataire_id,
2237                )
2238                .await;
2239            assert!(
2240                result.is_ok(),
2241                "Proxy vote {} should succeed, got: {:?}",
2242                i + 1,
2243                result.err()
2244            );
2245        }
2246
2247        // 4th proxy vote for the same mandataire should fail (>3 proxies AND >10% of votes)
2248        let owner_id_4 = Uuid::new_v4();
2249        let unit_id_4 = Uuid::new_v4();
2250        let result4 = use_cases
2251            .cast_vote(
2252                resolution.id,
2253                owner_id_4,
2254                unit_id_4,
2255                VoteChoice::Pour,
2256                Some(mandataire_id),
2257                Some(VoteAuthMethod::Proxy),
2258                mandataire_id,
2259            )
2260            .await;
2261        assert!(result4.is_err(), "4th proxy vote should be rejected");
2262        assert!(
2263            result4.unwrap_err().contains("3"),
2264            "Error should mention the 3-proxy limit"
2265        );
2266    }
2267
2268    /// @edge — Art. 3.87 § 7 : la limite de trois procurations s'applique même
2269    /// si le mandataire, pour son 4ᵉ vote, choisit de NE PAS se déclarer
2270    /// mandataire — c'est-à-dire d'envoyer l'`owner_id` de sa victime sans
2271    /// remplir `proxy_owner_id`.
2272    ///
2273    /// Avant #850, `validate_proxy_limit` n'était appelée QUE si
2274    /// `proxy_owner_id` était fourni (resolution_use_cases.rs:237 à l'époque
2275    /// du constat) : la limite légale ne s'appliquait donc qu'à ceux qui la
2276    /// respectaient déjà. Ce test reproduit exactement le contournement.
2277    #[tokio::test]
2278    async fn edge_la_limite_de_trois_procurations_sapplique_meme_non_declaree() {
2279        let resolution_repo = Arc::new(MockResolutionRepository::new());
2280        let vote_repo = Arc::new(MockVoteRepository::new());
2281        let meeting_repo = Arc::new(MockMeetingRepository::new());
2282        let unit_repo = Arc::new(MockUnitRepository::new());
2283        let use_cases = ResolutionUseCases::new(
2284            resolution_repo.clone(),
2285            vote_repo.clone(),
2286            meeting_repo.clone(),
2287            Arc::new(MockUnitOwnerRepository::new()),
2288            unit_repo.clone(),
2289        );
2290
2291        let org_id = Uuid::new_v4();
2292        let building_id = Uuid::new_v4();
2293        unit_repo.pose_defaut(building_id, dec!(100));
2294        let meeting_id = Uuid::new_v4();
2295
2296        let mut meeting = Meeting::new(
2297            Uuid::new_v4(),
2298            org_id,
2299            building_id,
2300            MeetingType::Ordinary,
2301            "AGO 2024".to_string(),
2302            None,
2303            Utc::now() + chrono::Duration::days(30),
2304            "Salle des fêtes".to_string(),
2305        )
2306        .unwrap();
2307        meeting.id = meeting_id;
2308        meeting
2309            .validate_quorum(
2310                rust_decimal_macros::dec!(600),
2311                rust_decimal_macros::dec!(1000),
2312            )
2313            .unwrap();
2314        meeting
2315            .add_agenda_item("Approbation des comptes".to_string())
2316            .unwrap();
2317        meeting_repo.create(&meeting).await.unwrap();
2318
2319        let resolution = use_cases
2320            .create_resolution(
2321                meeting_id,
2322                "Test contournement procurations".to_string(),
2323                "Description".to_string(),
2324                ResolutionType::Ordinary,
2325                MajorityType::Absolute,
2326                Some(0),
2327            )
2328            .await
2329            .unwrap();
2330
2331        let mandataire_id = Uuid::new_v4();
2332
2333        // Trois procurations DÉCLARÉES, correctement, pour le mandataire.
2334        for _ in 0..3 {
2335            let owner_id = Uuid::new_v4();
2336            let unit_id = Uuid::new_v4();
2337            let result = use_cases
2338                .cast_vote(
2339                    resolution.id,
2340                    owner_id,
2341                    unit_id,
2342                    VoteChoice::Pour,
2343                    Some(mandataire_id),
2344                    Some(VoteAuthMethod::Proxy),
2345                    mandataire_id,
2346                )
2347                .await;
2348            assert!(result.is_ok(), "les 3 premières procurations passent");
2349        }
2350
2351        // La quatrième : le mandataire envoie l'owner_id de sa victime SANS
2352        // remplir `proxy_owner_id`. C'est exactement le contournement que
2353        // #850 documente — et il doit être refusé, pas compté comme un vote
2354        // normal du copropriétaire visé.
2355        let victime_id = Uuid::new_v4();
2356        let unit_victime = Uuid::new_v4();
2357        let resultat = use_cases
2358            .cast_vote(
2359                resolution.id,
2360                victime_id,
2361                unit_victime,
2362                VoteChoice::Pour,
2363                None,
2364                Some(VoteAuthMethod::Presence),
2365                // L'appelant réel reste le mandataire, quoi que dise le corps
2366                // de la requête.
2367                mandataire_id,
2368            )
2369            .await;
2370
2371        assert!(
2372            resultat.is_err(),
2373            "le 4ᵉ vote non déclaré doit être refusé, pas silencieusement accepté"
2374        );
2375        let erreur = resultat.unwrap_err();
2376        assert!(
2377            erreur.contains("FORBIDDEN"),
2378            "le refus doit être typé usurpation/mandat non déclaré, got: {erreur}"
2379        );
2380    }
2381
2382    /// Art. 3.87 §7 CC — exception 10% : si total procurations < 10% → pas de limite
2383    #[tokio::test]
2384    async fn test_proxy_limit_10_percent_exception_allows_more() {
2385        let resolution_repo = Arc::new(MockResolutionRepository::new());
2386        let vote_repo = Arc::new(MockVoteRepository::new());
2387        let meeting_repo = Arc::new(MockMeetingRepository::new());
2388        let unit_repo = Arc::new(MockUnitRepository::new());
2389        let use_cases = ResolutionUseCases::new(
2390            resolution_repo.clone(),
2391            vote_repo.clone(),
2392            meeting_repo.clone(),
2393            Arc::new(MockUnitOwnerRepository::new()),
2394            unit_repo.clone(),
2395        );
2396
2397        let org_id = Uuid::new_v4();
2398        let building_id = Uuid::new_v4();
2399        // #850 — chaque lot voté existe désormais, et porte sa quotité.
2400        unit_repo.pose_defaut(building_id, dec!(100));
2401        let meeting_id = Uuid::new_v4();
2402
2403        // Create a meeting with quorum reached
2404        let mut meeting = Meeting::new(
2405            Uuid::new_v4(), // acp_id
2406            org_id,
2407            building_id,
2408            MeetingType::Ordinary,
2409            "AGO 2024".to_string(),
2410            None,
2411            Utc::now() + chrono::Duration::days(30),
2412            "Salle des fêtes".to_string(),
2413        )
2414        .unwrap();
2415        meeting.id = meeting_id;
2416        meeting
2417            .validate_quorum(
2418                rust_decimal_macros::dec!(600),
2419                rust_decimal_macros::dec!(1000),
2420            )
2421            .unwrap();
2422        // Un point d'ordre du jour, sans quoi aucune résolution ne peut être
2423        // mise aux voix : Art. 3.87 § 2 CC annule une décision portant sur un
2424        // point absent. Ces tests votaient jusqu'ici sur des résolutions
2425        // rattachées à rien — le cas exact que l'article annule.
2426        meeting
2427            .add_agenda_item("Approbation des comptes".to_string())
2428            .unwrap();
2429        meeting_repo.create(&meeting).await.unwrap();
2430
2431        let resolution = use_cases
2432            .create_resolution(
2433                meeting_id,
2434                "Test exception 10%".to_string(),
2435                "Description".to_string(),
2436                ResolutionType::Ordinary,
2437                MajorityType::Absolute,
2438                Some(0),
2439            )
2440            .await
2441            .unwrap();
2442
2443        let mandataire_id = Uuid::new_v4();
2444
2445        // First, add many direct votes to make the total large (900 millièmes direct)
2446        for _ in 0..9 {
2447            let owner_id = Uuid::new_v4();
2448            let _ = use_cases
2449                .cast_vote(
2450                    resolution.id,
2451                    owner_id,
2452                    Uuid::new_v4(),
2453                    VoteChoice::Pour,
2454                    None,
2455                    Some(VoteAuthMethod::Presence),
2456                    owner_id,
2457                )
2458                .await;
2459        }
2460
2461        // Quatre procurations sur de PETITS lots : 5 millièmes chacun, soit
2462        // 20 sur 920 → 2,2 % < 10 %, l'exception de l'Art. 3.87 § 7 s'applique.
2463        //
2464        // Le scénario passait auparavant par une puissance de vote déclarée
2465        // dans l'appel. Il passe désormais par la quotité RÉELLE de lots
2466        // modestes — ce qui est précisément le fait que l'exception vise : un
2467        // mandataire de petits lots ne pèse pas sur l'assemblée.
2468        for i in 0..4 {
2469            let petit_lot = Uuid::new_v4();
2470            unit_repo.lot(petit_lot, building_id, dec!(5));
2471            let result = use_cases
2472                .cast_vote(
2473                    resolution.id,
2474                    Uuid::new_v4(),
2475                    petit_lot,
2476                    VoteChoice::Pour,
2477                    Some(mandataire_id),
2478                    Some(VoteAuthMethod::Proxy),
2479                    mandataire_id,
2480                )
2481                .await;
2482            assert!(
2483                result.is_ok(),
2484                "Proxy vote {} (10% exception) should succeed, got: {:?}",
2485                i + 1,
2486                result.err()
2487            );
2488        }
2489    }
2490
2491    // ------------------------------------------------------------------------
2492    // Story H10 (CL3) — Gate quorum sur le chemin VOTE (Art. 3.87 §5).
2493    // ------------------------------------------------------------------------
2494
2495    /// @security — voter sur une AG sans quorum validé est rejeté (gate
2496    /// défense-en-profondeur sur `cast_vote`). On insère une résolution Pending
2497    /// directement (bypass `create_resolution`) avec une réunion SANS quorum.
2498    #[tokio::test]
2499    async fn security_cast_vote_rejected_without_quorum() {
2500        let resolution_repo = Arc::new(MockResolutionRepository::new());
2501        let vote_repo = Arc::new(MockVoteRepository::new());
2502        let meeting_repo = Arc::new(MockMeetingRepository::new());
2503        let use_cases = ResolutionUseCases::new(
2504            resolution_repo.clone(),
2505            vote_repo,
2506            meeting_repo.clone(),
2507            Arc::new(MockUnitOwnerRepository::new()),
2508            Arc::new(MockUnitRepository::new()),
2509        );
2510
2511        let meeting_id = Uuid::new_v4();
2512        let mut meeting = Meeting::new(
2513            Uuid::new_v4(), // acp_id
2514            Uuid::new_v4(),
2515            Uuid::new_v4(),
2516            MeetingType::Ordinary,
2517            "AGO sans quorum".to_string(),
2518            None,
2519            Utc::now() + chrono::Duration::days(30),
2520            "Salle".to_string(),
2521        )
2522        .unwrap();
2523        meeting.id = meeting_id;
2524        // PAS de validate_quorum → quorum_percentage = None.
2525        // Un point d'ordre du jour, sans quoi aucune résolution ne peut être
2526        // mise aux voix : Art. 3.87 § 2 CC annule une décision portant sur un
2527        // point absent. Ces tests votaient jusqu'ici sur des résolutions
2528        // rattachées à rien — le cas exact que l'article annule.
2529        meeting
2530            .add_agenda_item("Approbation des comptes".to_string())
2531            .unwrap();
2532        meeting_repo.create(&meeting).await.unwrap();
2533
2534        let mut resolution = Resolution::new(
2535            meeting_id,
2536            "R".to_string(),
2537            "D".to_string(),
2538            ResolutionType::Ordinary,
2539            MajorityType::Absolute,
2540            // Rattachée au point 0, sans quoi elle ne peut pas être mise aux
2541            // voix (Art. 3.87 § 2 CC). Ces deux tests éprouvent le droit de
2542            // vote d'un lot, pas l'ordre du jour : leur donner un point valide
2543            // évite qu'ils échouent pour une autre raison que la leur.
2544            Some(0),
2545        )
2546        .unwrap();
2547        resolution.status = ResolutionStatus::Pending;
2548        resolution_repo.create(&resolution).await.unwrap();
2549
2550        let owner_id = Uuid::new_v4();
2551        let err = use_cases
2552            .cast_vote(
2553                resolution.id,
2554                owner_id,
2555                Uuid::new_v4(),
2556                VoteChoice::Pour,
2557                None,
2558                Some(VoteAuthMethod::Presence),
2559                owner_id,
2560            )
2561            .await
2562            .unwrap_err();
2563        assert!(
2564            err.contains("Quorum"),
2565            "le vote sans quorum doit être rejeté, got: {}",
2566            err
2567        );
2568    }
2569
2570    /// @negative — résolution dont la réunion n'existe plus (drift data) →
2571    /// vote rejeté proprement (pas de panic).
2572    #[tokio::test]
2573    async fn negative_cast_vote_meeting_not_found() {
2574        let resolution_repo = Arc::new(MockResolutionRepository::new());
2575        let vote_repo = Arc::new(MockVoteRepository::new());
2576        let meeting_repo = Arc::new(MockMeetingRepository::new()); // vide
2577        let use_cases = ResolutionUseCases::new(
2578            resolution_repo.clone(),
2579            vote_repo,
2580            meeting_repo,
2581            Arc::new(MockUnitOwnerRepository::new()),
2582            Arc::new(MockUnitRepository::new()),
2583        );
2584
2585        let mut resolution = Resolution::new(
2586            Uuid::new_v4(), // meeting_id absent du repo
2587            "R".to_string(),
2588            "D".to_string(),
2589            ResolutionType::Ordinary,
2590            MajorityType::Absolute,
2591            // Rattachée au point 0 : Art. 3.87 § 2 CC interdit de mettre aux
2592            // voix une résolution hors ordre du jour. Ces tests éprouvent
2593            // autre chose, et doivent donc franchir cette garde.
2594            Some(0),
2595        )
2596        .unwrap();
2597        resolution.status = ResolutionStatus::Pending;
2598        resolution_repo.create(&resolution).await.unwrap();
2599
2600        let owner_id = Uuid::new_v4();
2601        let err = use_cases
2602            .cast_vote(
2603                resolution.id,
2604                owner_id,
2605                Uuid::new_v4(),
2606                VoteChoice::Pour,
2607                None,
2608                Some(VoteAuthMethod::Presence),
2609                owner_id,
2610            )
2611            .await
2612            .unwrap_err();
2613        assert!(
2614            err.contains("Meeting not found"),
2615            "réunion absente → erreur typée, got: {}",
2616            err
2617        );
2618    }
2619
2620    // ------------------------------------------------------------------------
2621    // Story H17 (CL3) — Gate droit de vote sur `cast_vote` (Art. 3.87 §1).
2622    // ------------------------------------------------------------------------
2623
2624    /// Helper — réunion avec quorum validé + résolution Pending prête à voter.
2625    /// Une assemblée en quorum, avec sa résolution — et **les lots de son
2626    /// immeuble** (#850).
2627    ///
2628    /// Le dépôt de lots entre dans la signature parce que voter exige
2629    /// désormais un lot qui existe, rattaché à l'immeuble de l'assemblée. Le
2630    /// passer en paramètre plutôt que de laisser chaque test y penser évite
2631    /// qu'un scénario futur échoue sur « Lot introuvable » sans rapport avec
2632    /// ce qu'il éprouve.
2633    async fn meeting_with_quorum_and_resolution(
2634        resolution_repo: &Arc<MockResolutionRepository>,
2635        meeting_repo: &Arc<MockMeetingRepository>,
2636        unit_repo: &Arc<MockUnitRepository>,
2637    ) -> Resolution {
2638        let meeting_id = Uuid::new_v4();
2639        let building_id = Uuid::new_v4();
2640        unit_repo.pose_defaut(building_id, dec!(100));
2641        let mut meeting = Meeting::new(
2642            Uuid::new_v4(), // acp_id
2643            Uuid::new_v4(),
2644            building_id,
2645            MeetingType::Ordinary,
2646            "AGO".to_string(),
2647            None,
2648            Utc::now() + chrono::Duration::days(30),
2649            "Salle".to_string(),
2650        )
2651        .unwrap();
2652        meeting.id = meeting_id;
2653        meeting
2654            .validate_quorum(
2655                rust_decimal_macros::dec!(600),
2656                rust_decimal_macros::dec!(1000),
2657            )
2658            .unwrap();
2659        // Un point d'ordre du jour, sans quoi aucune résolution ne peut être
2660        // mise aux voix : Art. 3.87 § 2 CC annule une décision portant sur un
2661        // point absent. Ces tests votaient jusqu'ici sur des résolutions
2662        // rattachées à rien — le cas exact que l'article annule.
2663        meeting
2664            .add_agenda_item("Approbation des comptes".to_string())
2665            .unwrap();
2666        meeting_repo.create(&meeting).await.unwrap();
2667
2668        let mut resolution = Resolution::new(
2669            meeting_id,
2670            "R".to_string(),
2671            "D".to_string(),
2672            ResolutionType::Ordinary,
2673            MajorityType::Absolute,
2674            // Rattachée au point 0 : Art. 3.87 § 2 CC interdit de mettre aux
2675            // voix une résolution hors ordre du jour. Ces tests éprouvent
2676            // autre chose, et doivent donc franchir cette garde.
2677            Some(0),
2678        )
2679        .unwrap();
2680        resolution.status = ResolutionStatus::Pending;
2681        resolution_repo.create(&resolution).await.unwrap();
2682        resolution
2683    }
2684
2685    /// Story 4.2 — même montage que `meeting_with_quorum_and_resolution`,
2686    /// mais l'AG est tenue en mode `Remote` (Art. 3.87 §1er CC) : c'est cette
2687    /// modalité qui déclenche l'exigence d'authentification forte.
2688    async fn meeting_with_quorum_and_resolution_remote(
2689        resolution_repo: &Arc<MockResolutionRepository>,
2690        meeting_repo: &Arc<MockMeetingRepository>,
2691        unit_repo: &Arc<MockUnitRepository>,
2692    ) -> Resolution {
2693        let meeting_id = Uuid::new_v4();
2694        let building_id = Uuid::new_v4();
2695        unit_repo.pose_defaut(building_id, dec!(100));
2696        let mut meeting = Meeting::new(
2697            Uuid::new_v4(), // acp_id
2698            Uuid::new_v4(),
2699            building_id,
2700            MeetingType::Ordinary,
2701            "AGO distancielle".to_string(),
2702            None,
2703            Utc::now() + chrono::Duration::days(30),
2704            "Visioconférence".to_string(),
2705        )
2706        .unwrap();
2707        meeting.id = meeting_id;
2708        meeting
2709            .set_mode(
2710                MeetingMode::Remote,
2711                Some("https://visio.example/ago".to_string()),
2712            )
2713            .unwrap();
2714        meeting
2715            .validate_quorum(
2716                rust_decimal_macros::dec!(600),
2717                rust_decimal_macros::dec!(1000),
2718            )
2719            .unwrap();
2720        meeting
2721            .add_agenda_item("Approbation des comptes".to_string())
2722            .unwrap();
2723        meeting_repo.create(&meeting).await.unwrap();
2724
2725        let mut resolution = Resolution::new(
2726            meeting_id,
2727            "R".to_string(),
2728            "D".to_string(),
2729            ResolutionType::Ordinary,
2730            MajorityType::Absolute,
2731            Some(0),
2732        )
2733        .unwrap();
2734        resolution.status = ResolutionStatus::Pending;
2735        resolution_repo.create(&resolution).await.unwrap();
2736        resolution
2737    }
2738
2739    // ------------------------------------------------------------------------
2740    // Story 4.2 — `cast_vote` × `auth_method` (Art. 3.87 §1er, §4 CC, #48)
2741    // ------------------------------------------------------------------------
2742
2743    /// @happy — un vote distant authentifié par itsme est enregistré avec
2744    /// `auth_method = itsme`.
2745    #[tokio::test]
2746    async fn happy_vote_distant_avec_itsme_est_enregistre() {
2747        let resolution_repo = Arc::new(MockResolutionRepository::new());
2748        let vote_repo = Arc::new(MockVoteRepository::new());
2749        let meeting_repo = Arc::new(MockMeetingRepository::new());
2750        let unit_repo = Arc::new(MockUnitRepository::new());
2751        let resolution =
2752            meeting_with_quorum_and_resolution_remote(&resolution_repo, &meeting_repo, &unit_repo)
2753                .await;
2754        let use_cases = ResolutionUseCases::new(
2755            resolution_repo.clone(),
2756            vote_repo,
2757            meeting_repo,
2758            Arc::new(MockUnitOwnerRepository::new()),
2759            unit_repo,
2760        );
2761
2762        let owner_id = Uuid::new_v4();
2763        let unit_id = Uuid::new_v4();
2764        let vote = use_cases
2765            .cast_vote(
2766                resolution.id,
2767                owner_id,
2768                unit_id,
2769                VoteChoice::Pour,
2770                None,
2771                Some(VoteAuthMethod::Itsme),
2772                owner_id,
2773            )
2774            .await
2775            .expect("un vote itsme sur une AG distancielle doit aboutir");
2776
2777        assert_eq!(vote.auth_method, VoteAuthMethod::Itsme);
2778    }
2779
2780    /// @edge — un vote distant par procuration en bonne et due forme est
2781    /// autorisé (Art. 3.87 §4) : la procuration elle-même reste régie par ses
2782    /// propres conditions (limite de trois mandats), pas par cette garde.
2783    #[tokio::test]
2784    async fn edge_vote_distant_par_procuration_est_autorise() {
2785        let resolution_repo = Arc::new(MockResolutionRepository::new());
2786        let vote_repo = Arc::new(MockVoteRepository::new());
2787        let meeting_repo = Arc::new(MockMeetingRepository::new());
2788        let unit_repo = Arc::new(MockUnitRepository::new());
2789        let resolution =
2790            meeting_with_quorum_and_resolution_remote(&resolution_repo, &meeting_repo, &unit_repo)
2791                .await;
2792        let use_cases = ResolutionUseCases::new(
2793            resolution_repo.clone(),
2794            vote_repo,
2795            meeting_repo,
2796            Arc::new(MockUnitOwnerRepository::new()),
2797            unit_repo,
2798        );
2799
2800        let mandant_id = Uuid::new_v4();
2801        let mandataire_id = Uuid::new_v4();
2802        let unit_id = Uuid::new_v4();
2803        let vote = use_cases
2804            .cast_vote(
2805                resolution.id,
2806                mandant_id,
2807                unit_id,
2808                VoteChoice::Pour,
2809                Some(mandataire_id),
2810                Some(VoteAuthMethod::Proxy),
2811                mandataire_id,
2812            )
2813            .await
2814            .expect("une procuration réelle doit être autorisée à distance");
2815
2816        assert_eq!(vote.auth_method, VoteAuthMethod::Proxy);
2817    }
2818
2819    /// @security — déclarer `presence` pour un vote distant est exactement la
2820    /// fraude que l'authentification forte doit rendre impossible (#48) :
2821    /// refusé en 403 typé `VOTE_AUTH_INSUFFICIENT`, pas silencieusement
2822    /// accepté.
2823    #[tokio::test]
2824    async fn security_vote_distant_avec_presence_est_refuse_403() {
2825        let resolution_repo = Arc::new(MockResolutionRepository::new());
2826        let vote_repo = Arc::new(MockVoteRepository::new());
2827        let meeting_repo = Arc::new(MockMeetingRepository::new());
2828        let unit_repo = Arc::new(MockUnitRepository::new());
2829        let resolution =
2830            meeting_with_quorum_and_resolution_remote(&resolution_repo, &meeting_repo, &unit_repo)
2831                .await;
2832        let use_cases = ResolutionUseCases::new(
2833            resolution_repo.clone(),
2834            vote_repo,
2835            meeting_repo,
2836            Arc::new(MockUnitOwnerRepository::new()),
2837            unit_repo,
2838        );
2839
2840        let owner_id = Uuid::new_v4();
2841        let unit_id = Uuid::new_v4();
2842        let erreur = use_cases
2843            .cast_vote(
2844                resolution.id,
2845                owner_id,
2846                unit_id,
2847                VoteChoice::Pour,
2848                None,
2849                Some(VoteAuthMethod::Presence),
2850                owner_id,
2851            )
2852            .await
2853            .expect_err("presence ne doit pas suffire à distance");
2854
2855        assert_eq!(
2856            erreur, "VOTE_AUTH_INSUFFICIENT:remote:presence",
2857            "le refus doit être typé, parsable par le handler : {erreur}"
2858        );
2859        let app_err: crate::application::error::AppError =
2860            crate::domain::entities::VoteAuthError::Insufficient {
2861                mode: MeetingMode::Remote,
2862                auth_method: VoteAuthMethod::Presence,
2863            }
2864            .into();
2865        assert_eq!(
2866            app_err.status_code(),
2867            actix_web::http::StatusCode::FORBIDDEN
2868        );
2869    }
2870
2871    /// @negative — un vote sans `auth_method` est refusé en 422 typé
2872    /// `VOTE_AUTH_METHOD_REQUIRED`, quel que soit le mode de l'AG.
2873    #[tokio::test]
2874    async fn negative_vote_sans_auth_method_est_refuse_422() {
2875        let resolution_repo = Arc::new(MockResolutionRepository::new());
2876        let vote_repo = Arc::new(MockVoteRepository::new());
2877        let meeting_repo = Arc::new(MockMeetingRepository::new());
2878        let unit_repo = Arc::new(MockUnitRepository::new());
2879        let resolution =
2880            meeting_with_quorum_and_resolution(&resolution_repo, &meeting_repo, &unit_repo).await;
2881        let use_cases = ResolutionUseCases::new(
2882            resolution_repo.clone(),
2883            vote_repo,
2884            meeting_repo,
2885            Arc::new(MockUnitOwnerRepository::new()),
2886            unit_repo,
2887        );
2888
2889        let owner_id = Uuid::new_v4();
2890        let unit_id = Uuid::new_v4();
2891        let erreur = use_cases
2892            .cast_vote(
2893                resolution.id,
2894                owner_id,
2895                unit_id,
2896                VoteChoice::Pour,
2897                None,
2898                None,
2899                owner_id,
2900            )
2901            .await
2902            .expect_err("un vote sans auth_method ne peut pas être enregistré");
2903
2904        assert_eq!(
2905            erreur, "VOTE_AUTH_METHOD_REQUIRED",
2906            "le refus doit être typé, parsable par le handler : {erreur}"
2907        );
2908        let app_err: crate::application::error::AppError =
2909            crate::domain::entities::VoteAuthError::Missing.into();
2910        assert_eq!(
2911            app_err.status_code(),
2912            actix_web::http::StatusCode::UNPROCESSABLE_ENTITY
2913        );
2914    }
2915
2916    /// @security — une résolution hors ordre du jour ne peut pas être mise aux
2917    /// voix (Art. 3.87 § 2 CC).
2918    ///
2919    /// La validation existait à la création et ne s'appliquait qu'aux
2920    /// résolutions qui DÉCLARENT un point : `if let Some(index)` laissait
2921    /// passer `None`. Une résolution rattachée à rien était créée, votée et
2922    /// clôturée — le cas exact que l'article annule.
2923    ///
2924    /// Le refus est au VOTE et non à la création : l'article annule la
2925    /// décision, pas la proposition (#840, voie 2 sur 3).
2926    #[tokio::test]
2927    async fn security_vote_refuse_sur_resolution_hors_ordre_du_jour() {
2928        let resolution_repo = Arc::new(MockResolutionRepository::new());
2929        let vote_repo = Arc::new(MockVoteRepository::new());
2930        let meeting_repo = Arc::new(MockMeetingRepository::new());
2931        let unit_repo = Arc::new(MockUnitRepository::new());
2932        let resolution =
2933            meeting_with_quorum_and_resolution(&resolution_repo, &meeting_repo, &unit_repo).await;
2934
2935        // On détache la résolution de son point d'ordre du jour.
2936        let mut orpheline = resolution.clone();
2937        orpheline.agenda_item_index = None;
2938        resolution_repo.create(&orpheline).await.unwrap();
2939
2940        let unit_id = Uuid::new_v4();
2941        let unit_owner_repo = Arc::new(MockUnitOwnerRepository::with_holders(
2942            unit_id,
2943            vec![LotHolder::new(OwnershipType::FullOwner, false)],
2944        ));
2945        let use_cases = ResolutionUseCases::new(
2946            resolution_repo.clone(),
2947            vote_repo,
2948            meeting_repo,
2949            unit_owner_repo,
2950            // #850 — la quotité du lot se lit sur l'acte de base.
2951            Arc::new(MockUnitRepository::new()),
2952        );
2953
2954        let owner_id = Uuid::new_v4();
2955        let erreur = use_cases
2956            .cast_vote(
2957                orpheline.id,
2958                owner_id,
2959                unit_id,
2960                VoteChoice::Pour,
2961                None,
2962                Some(VoteAuthMethod::Presence),
2963                owner_id,
2964            )
2965            .await
2966            .expect_err("une résolution hors ordre du jour ne doit pas être votable");
2967
2968        assert!(
2969            erreur.contains("ordre du jour") && erreur.contains("3.87"),
2970            "le refus doit citer l'ordre du jour et l'article : {erreur}"
2971        );
2972        assert!(
2973            erreur.contains("nulle"),
2974            "le refus doit dire POURQUOI — la décision serait nulle : {erreur}"
2975        );
2976    }
2977
2978    /// @security — la garde a trois branches ; une seule était éprouvée.
2979    ///
2980    /// Un index qui désigne un point INEXISTANT est aussi grave qu'un index
2981    /// absent : la résolution prétend porter sur un point de l'ordre du jour,
2982    /// et ce point n'existe pas. Sans ce test, la branche serait du code que
2983    /// rien n'exécute, et qui pourrait être supprimé sans qu'un test bronche.
2984    #[tokio::test]
2985    async fn security_vote_refuse_sur_point_inexistant() {
2986        let resolution_repo = Arc::new(MockResolutionRepository::new());
2987        let vote_repo = Arc::new(MockVoteRepository::new());
2988        let meeting_repo = Arc::new(MockMeetingRepository::new());
2989        let unit_repo = Arc::new(MockUnitRepository::new());
2990        let resolution =
2991            meeting_with_quorum_and_resolution(&resolution_repo, &meeting_repo, &unit_repo).await;
2992
2993        // L'assemblée ne compte qu'un point (n° 0) : le n° 7 n'existe pas.
2994        let mut hors_bornes = resolution.clone();
2995        hors_bornes.agenda_item_index = Some(7);
2996        resolution_repo.create(&hors_bornes).await.unwrap();
2997
2998        let unit_id = Uuid::new_v4();
2999        let unit_owner_repo = Arc::new(MockUnitOwnerRepository::with_holders(
3000            unit_id,
3001            vec![LotHolder::new(OwnershipType::FullOwner, false)],
3002        ));
3003        let use_cases = ResolutionUseCases::new(
3004            resolution_repo.clone(),
3005            vote_repo,
3006            meeting_repo,
3007            unit_owner_repo,
3008            // #850 — la quotité du lot se lit sur l'acte de base.
3009            Arc::new(MockUnitRepository::new()),
3010        );
3011
3012        let owner_id = Uuid::new_v4();
3013        let erreur = use_cases
3014            .cast_vote(
3015                hors_bornes.id,
3016                owner_id,
3017                unit_id,
3018                VoteChoice::Pour,
3019                None,
3020                Some(VoteAuthMethod::Presence),
3021                owner_id,
3022            )
3023            .await
3024            .expect_err("un point d'ordre du jour inexistant ne doit pas être votable");
3025
3026        assert!(
3027            erreur.contains("n'existe pas") && erreur.contains("3.87"),
3028            "le refus doit dire que le point n'existe pas, et citer l'article : {erreur}"
3029        );
3030    }
3031
3032    /// @security — un point d'ordre du jour VIDE ne renseigne personne sur ce
3033    /// qui est mis aux voix. La convocation doit énoncer l'objet des décisions
3034    /// (Art. 3.87 § 2 CC) ; un intitulé blanc n'énonce rien.
3035    #[tokio::test]
3036    async fn security_vote_refuse_sur_point_dordre_du_jour_vide() {
3037        let resolution_repo = Arc::new(MockResolutionRepository::new());
3038        let vote_repo = Arc::new(MockVoteRepository::new());
3039        let meeting_repo = Arc::new(MockMeetingRepository::new());
3040        let unit_repo = Arc::new(MockUnitRepository::new());
3041        let resolution =
3042            meeting_with_quorum_and_resolution(&resolution_repo, &meeting_repo, &unit_repo).await;
3043
3044        // On blanchit l'intitulé du point 0, auquel la résolution est rattachée.
3045        let mut reunion = meeting_repo
3046            .find_by_id(resolution.meeting_id)
3047            .await
3048            .unwrap()
3049            .expect("l'assemblée du helper doit exister");
3050        reunion.agenda = vec!["   ".to_string()];
3051        meeting_repo.update(&reunion).await.unwrap();
3052
3053        let unit_id = Uuid::new_v4();
3054        let unit_owner_repo = Arc::new(MockUnitOwnerRepository::with_holders(
3055            unit_id,
3056            vec![LotHolder::new(OwnershipType::FullOwner, false)],
3057        ));
3058        let use_cases = ResolutionUseCases::new(
3059            resolution_repo.clone(),
3060            vote_repo,
3061            meeting_repo,
3062            unit_owner_repo,
3063            // #850 — la quotité du lot se lit sur l'acte de base.
3064            Arc::new(MockUnitRepository::new()),
3065        );
3066
3067        let owner_id = Uuid::new_v4();
3068        let erreur = use_cases
3069            .cast_vote(
3070                resolution.id,
3071                owner_id,
3072                unit_id,
3073                VoteChoice::Pour,
3074                None,
3075                Some(VoteAuthMethod::Presence),
3076                owner_id,
3077            )
3078            .await
3079            .expect_err("un point vide ne doit pas être votable");
3080
3081        assert!(
3082            erreur.contains("vide") && erreur.contains("3.87"),
3083            "le refus doit dire que le point est vide, et citer l'article : {erreur}"
3084        );
3085    }
3086
3087    /// @security — un lot en indivision SANS représentant unique désigné a son
3088    /// droit de vote suspendu (Art. 3.87 §1) : `cast_vote` est rejeté avec
3089    /// `VOTING_RIGHT_SUSPENDED`, et le lot ne contribue donc pas au quorum.
3090    #[tokio::test]
3091    async fn security_cast_vote_rejected_when_voting_right_suspended() {
3092        let resolution_repo = Arc::new(MockResolutionRepository::new());
3093        let vote_repo = Arc::new(MockVoteRepository::new());
3094        let meeting_repo = Arc::new(MockMeetingRepository::new());
3095        let unit_repo = Arc::new(MockUnitRepository::new());
3096        let resolution =
3097            meeting_with_quorum_and_resolution(&resolution_repo, &meeting_repo, &unit_repo).await;
3098
3099        let unit_id = Uuid::new_v4();
3100        // Lot indivis (2 indivisaires), aucun représentant désigné → suspendu.
3101        let unit_owner_repo = Arc::new(MockUnitOwnerRepository::with_holders(
3102            unit_id,
3103            vec![
3104                LotHolder::new(OwnershipType::Indivisaire, false),
3105                LotHolder::new(OwnershipType::Indivisaire, false),
3106            ],
3107        ));
3108        let use_cases = ResolutionUseCases::new(
3109            resolution_repo.clone(),
3110            vote_repo,
3111            meeting_repo,
3112            unit_owner_repo,
3113            // #850 — la quotité du lot se lit sur l'acte de base.
3114            Arc::new(MockUnitRepository::new()),
3115        );
3116
3117        let owner_id = Uuid::new_v4();
3118        let err = use_cases
3119            .cast_vote(
3120                resolution.id,
3121                owner_id,
3122                unit_id,
3123                VoteChoice::Pour,
3124                None,
3125                Some(VoteAuthMethod::Presence),
3126                owner_id,
3127            )
3128            .await
3129            .unwrap_err();
3130        assert!(
3131            err.contains("VOTING_RIGHT_SUSPENDED"),
3132            "lot suspendu → vote rejeté typé, got: {}",
3133            err
3134        );
3135    }
3136
3137    /// @happy — un lot démembré AVEC représentant unique désigné peut voter :
3138    /// le gate H17 laisse passer (Art. 3.87 §1).
3139    #[tokio::test]
3140    async fn happy_cast_vote_allowed_with_designated_representative() {
3141        let resolution_repo = Arc::new(MockResolutionRepository::new());
3142        let vote_repo = Arc::new(MockVoteRepository::new());
3143        let meeting_repo = Arc::new(MockMeetingRepository::new());
3144        let unit_repo = Arc::new(MockUnitRepository::new());
3145        let resolution =
3146            meeting_with_quorum_and_resolution(&resolution_repo, &meeting_repo, &unit_repo).await;
3147
3148        let unit_id = Uuid::new_v4();
3149        // Usufruit + nue-propriété, l'usufruitier est le représentant → actif.
3150        let unit_owner_repo = Arc::new(MockUnitOwnerRepository::with_holders(
3151            unit_id,
3152            vec![
3153                LotHolder::new(OwnershipType::Usufruct, true),
3154                LotHolder::new(OwnershipType::BareOwner, false),
3155            ],
3156        ));
3157        let use_cases = ResolutionUseCases::new(
3158            resolution_repo.clone(),
3159            vote_repo,
3160            meeting_repo,
3161            unit_owner_repo,
3162            // #850 — la quotité du lot se lit sur l'acte de base.
3163            unit_repo.clone(),
3164        );
3165
3166        let owner_id = Uuid::new_v4();
3167        let result = use_cases
3168            .cast_vote(
3169                resolution.id,
3170                owner_id,
3171                unit_id,
3172                VoteChoice::Pour,
3173                None,
3174                Some(VoteAuthMethod::Presence),
3175                owner_id,
3176            )
3177            .await;
3178        assert!(
3179            result.is_ok(),
3180            "lot avec représentant désigné doit pouvoir voter, got: {:?}",
3181            result.err()
3182        );
3183    }
3184
3185    /// @negative — usurpation : l'appelant vote au nom d'un `owner_id` qui
3186    /// n'est pas le sien, sans se déclarer mandataire. Aujourd'hui (avant
3187    /// #850) c'était accepté : rien ne rapprochait cet identifiant de celui
3188    /// de l'appelant.
3189    #[tokio::test]
3190    async fn negative_voter_au_nom_dautrui_sans_procuration_est_refuse() {
3191        let resolution_repo = Arc::new(MockResolutionRepository::new());
3192        let vote_repo = Arc::new(MockVoteRepository::new());
3193        let meeting_repo = Arc::new(MockMeetingRepository::new());
3194        let unit_repo = Arc::new(MockUnitRepository::new());
3195        let resolution =
3196            meeting_with_quorum_and_resolution(&resolution_repo, &meeting_repo, &unit_repo).await;
3197        let use_cases = ResolutionUseCases::new(
3198            resolution_repo.clone(),
3199            vote_repo.clone(),
3200            meeting_repo.clone(),
3201            Arc::new(MockUnitOwnerRepository::new()),
3202            unit_repo.clone(),
3203        );
3204
3205        let victime_id = Uuid::new_v4();
3206        let attaquant_id = Uuid::new_v4();
3207        let unit_id = Uuid::new_v4();
3208
3209        let resultat = use_cases
3210            .cast_vote(
3211                resolution.id,
3212                victime_id, // le vote prétend être émis par la victime…
3213                unit_id,
3214                VoteChoice::Pour,
3215                None, // … sans que l'attaquant se déclare son mandataire.
3216                Some(VoteAuthMethod::Presence),
3217                attaquant_id, // … alors que l'appelant réel est un tiers.
3218            )
3219            .await;
3220
3221        assert!(
3222            resultat.is_err(),
3223            "voter au nom d'autrui sans procuration doit être refusé"
3224        );
3225        let erreur = resultat.unwrap_err();
3226        assert!(
3227            erreur.contains("FORBIDDEN"),
3228            "le refus doit être typé (403), pas une chaîne quelconque : {erreur}"
3229        );
3230
3231        // Aucun vote ne doit avoir été enregistré : le refus est total, pas
3232        // une simple erreur cosmétique sur un enregistrement déjà fait.
3233        assert!(
3234            !vote_repo
3235                .has_voted(resolution.id, unit_id)
3236                .await
3237                .expect("lecture du dépôt"),
3238            "aucun vote ne doit être enregistré après un refus d'usurpation"
3239        );
3240    }
3241
3242    /// @negative — se déclarer mandataire de quelqu'un d'autre que soi-même
3243    /// n'a pas de sens et doit être refusé, pas silencieusement corrigé.
3244    #[tokio::test]
3245    async fn negative_se_declarer_mandataire_dun_tiers_est_refuse() {
3246        let resolution_repo = Arc::new(MockResolutionRepository::new());
3247        let vote_repo = Arc::new(MockVoteRepository::new());
3248        let meeting_repo = Arc::new(MockMeetingRepository::new());
3249        let unit_repo = Arc::new(MockUnitRepository::new());
3250        let resolution =
3251            meeting_with_quorum_and_resolution(&resolution_repo, &meeting_repo, &unit_repo).await;
3252        let use_cases = ResolutionUseCases::new(
3253            resolution_repo.clone(),
3254            vote_repo.clone(),
3255            meeting_repo.clone(),
3256            Arc::new(MockUnitOwnerRepository::new()),
3257            unit_repo.clone(),
3258        );
3259
3260        let owner_id = Uuid::new_v4();
3261        let appelant_id = Uuid::new_v4();
3262        let quelquun_dautre = Uuid::new_v4();
3263
3264        let resultat = use_cases
3265            .cast_vote(
3266                resolution.id,
3267                owner_id,
3268                Uuid::new_v4(),
3269                VoteChoice::Pour,
3270                Some(quelquun_dautre), // déclare un mandataire qui n'est pas l'appelant
3271                Some(VoteAuthMethod::Presence),
3272                appelant_id,
3273            )
3274            .await;
3275
3276        assert!(
3277            resultat.is_err(),
3278            "une procuration déclarée pour un tiers doit être refusée"
3279        );
3280        assert!(resultat.unwrap_err().contains("FORBIDDEN"));
3281    }
3282
3283    /// @negative — le lot n'appartient pas à `owner_id` : même en votant
3284    /// « pour soi-même » (`owner_id == caller_owner_id`), si le dépôt ne
3285    /// rattache pas ce copropriétaire au lot, le vote est refusé.
3286    #[tokio::test]
3287    async fn negative_voter_pour_un_lot_quon_ne_detient_pas_est_refuse() {
3288        let resolution_repo = Arc::new(MockResolutionRepository::new());
3289        let vote_repo = Arc::new(MockVoteRepository::new());
3290        let meeting_repo = Arc::new(MockMeetingRepository::new());
3291        let unit_repo = Arc::new(MockUnitRepository::new());
3292        let resolution =
3293            meeting_with_quorum_and_resolution(&resolution_repo, &meeting_repo, &unit_repo).await;
3294
3295        let owner_id = Uuid::new_v4();
3296        let unit_id = Uuid::new_v4();
3297        let unit_owner_repo = Arc::new(MockUnitOwnerRepository::new());
3298        unit_owner_repo.refuse_detention(unit_id, owner_id);
3299
3300        let use_cases = ResolutionUseCases::new(
3301            resolution_repo.clone(),
3302            vote_repo.clone(),
3303            meeting_repo.clone(),
3304            unit_owner_repo,
3305            unit_repo.clone(),
3306        );
3307
3308        let resultat = use_cases
3309            .cast_vote(
3310                resolution.id,
3311                owner_id,
3312                unit_id,
3313                VoteChoice::Pour,
3314                None,
3315                Some(VoteAuthMethod::Presence),
3316                owner_id,
3317            )
3318            .await;
3319
3320        assert!(
3321            resultat.is_err(),
3322            "voter pour un lot qu'on ne détient pas doit être refusé"
3323        );
3324        let erreur = resultat.unwrap_err();
3325        assert!(
3326            erreur.contains("FORBIDDEN") && erreur.contains("ne détient pas"),
3327            "le refus doit dire POURQUOI, got: {erreur}"
3328        );
3329    }
3330
3331    // ------------------------------------------------------------------------
3332    // Story 4.6 — ResolutionAutoNotRemovable guard (#581)
3333    // ------------------------------------------------------------------------
3334
3335    fn build_use_cases_pour_resolution(
3336        resolution_repo: Arc<MockResolutionRepository>,
3337        vote_repo: Arc<MockVoteRepository>,
3338        meeting_repo: Arc<MockMeetingRepository>,
3339    ) -> ResolutionUseCases {
3340        ResolutionUseCases::new(
3341            resolution_repo,
3342            vote_repo,
3343            meeting_repo,
3344            Arc::new(MockUnitOwnerRepository::new()),
3345            Arc::new(MockUnitRepository::new()),
3346        )
3347    }
3348
3349    /// @security — le syndic tente de supprimer la résolution d'évaluation
3350    /// des prestataires générée d'office : refus typé 403
3351    /// (`ResolutionAutoNotRemovable`), jamais un 400 générique. C'est le
3352    /// cœur de la story : le point évalue le syndic, il ne peut pas le
3353    /// retirer lui-même de l'ordre du jour.
3354    #[tokio::test]
3355    async fn security_delete_resolution_auto_generated_refuse() {
3356        let resolution_repo = Arc::new(MockResolutionRepository::new());
3357        let auto = Resolution::new_evaluation_contractors_auto(Uuid::new_v4());
3358        resolution_repo.create(&auto).await.unwrap();
3359        let use_cases = build_use_cases_pour_resolution(
3360            resolution_repo,
3361            Arc::new(MockVoteRepository::new()),
3362            Arc::new(MockMeetingRepository::new()),
3363        );
3364
3365        let result = use_cases.delete_resolution(auto.id).await;
3366
3367        assert!(matches!(result, Err(AppError::ResolutionAutoNotRemovable)));
3368    }
3369
3370    /// @negative — modifier le texte d'une résolution auto-générée est
3371    /// refusé même si son statut est `Pending`, le cas normalement
3372    /// modifiable : l'immutabilité de Story 4.6 ne dépend pas du statut.
3373    #[tokio::test]
3374    async fn negative_update_resolution_auto_generated_refuse() {
3375        let use_cases = build_use_cases_pour_resolution(
3376            Arc::new(MockResolutionRepository::new()),
3377            Arc::new(MockVoteRepository::new()),
3378            Arc::new(MockMeetingRepository::new()),
3379        );
3380        let mut auto = Resolution::new_evaluation_contractors_auto(Uuid::new_v4());
3381        auto.description = "Texte modifié par le syndic".to_string();
3382
3383        let result = use_cases.update_resolution(&auto).await;
3384
3385        assert!(matches!(result, Err(AppError::ResolutionAutoNotRemovable)));
3386    }
3387
3388    /// @happy — une résolution standard, sans vote, reste supprimable comme
3389    /// avant Story 4.6 : la nouvelle garde ne touche pas au cas nominal.
3390    #[tokio::test]
3391    async fn happy_delete_resolution_standard_sans_votes_reussit() {
3392        let resolution_repo = Arc::new(MockResolutionRepository::new());
3393        let standard = Resolution::new(
3394            Uuid::new_v4(),
3395            "Titre".to_string(),
3396            "Description".to_string(),
3397            ResolutionType::Ordinary,
3398            MajorityType::Absolute,
3399            None,
3400        )
3401        .unwrap();
3402        resolution_repo.create(&standard).await.unwrap();
3403        let use_cases = build_use_cases_pour_resolution(
3404            resolution_repo,
3405            Arc::new(MockVoteRepository::new()),
3406            Arc::new(MockMeetingRepository::new()),
3407        );
3408
3409        let result = use_cases.delete_resolution(standard.id).await;
3410
3411        assert!(result.unwrap());
3412    }
3413
3414    /// @edge — supprimer une résolution introuvable rend un 404 typé, pas un
3415    /// panic ni un 400 générique.
3416    #[tokio::test]
3417    async fn edge_delete_resolution_introuvable_rend_404() {
3418        let use_cases = build_use_cases_pour_resolution(
3419            Arc::new(MockResolutionRepository::new()),
3420            Arc::new(MockVoteRepository::new()),
3421            Arc::new(MockMeetingRepository::new()),
3422        );
3423
3424        let result = use_cases.delete_resolution(Uuid::new_v4()).await;
3425
3426        assert!(matches!(result, Err(AppError::NotFound(_))));
3427    }
3428}