Skip to main content

koprogo_api/domain/copropriete/
unit_owner.rs

1use chrono::{DateTime, Utc};
2use rust_decimal::Decimal;
3use uuid::Uuid;
4
5/// UnitOwner represents the ownership relationship between a Unit and an Owner
6/// This entity supports:
7/// - Multiple owners per unit (co-ownership, indivision)
8/// - Multiple units per owner (owner in multiple buildings)
9/// - Ownership percentage tracking
10/// - Historical ownership tracking (start_date, end_date)
11///
12/// MONETARY-ADJACENT: ownership_percentage uses rust_decimal::Decimal (cf. ADR-0007).
13/// Quote-parts drive charge distribution; rounding errors propagate to invoices.
14#[derive(Debug, Clone)]
15pub struct UnitOwner {
16    pub id: Uuid,
17    pub unit_id: Uuid,
18    pub owner_id: Uuid,
19
20    /// Ownership percentage (0.0 to 1.0). Decimal exact (cf. ADR-0007).
21    /// Example: dec!(0.5) = 50%, dec!(1.0) = 100%
22    pub ownership_percentage: Decimal,
23
24    /// Date when ownership started
25    pub start_date: DateTime<Utc>,
26
27    /// Date when ownership ended (None = current owner)
28    pub end_date: Option<DateTime<Utc>>,
29
30    /// Is this owner the primary contact for this unit?
31    pub is_primary_contact: bool,
32
33    pub created_at: DateTime<Utc>,
34    pub updated_at: DateTime<Utc>,
35}
36
37impl UnitOwner {
38    /// Create a new UnitOwner relationship
39    pub fn new(
40        unit_id: Uuid,
41        owner_id: Uuid,
42        ownership_percentage: Decimal,
43        is_primary_contact: bool,
44    ) -> Result<Self, String> {
45        // Validate ownership percentage
46        if ownership_percentage <= Decimal::ZERO || ownership_percentage > Decimal::ONE {
47            return Err("Ownership percentage must be between 0 and 1".to_string());
48        }
49
50        Ok(Self {
51            id: Uuid::new_v4(),
52            unit_id,
53            owner_id,
54            ownership_percentage,
55            start_date: Utc::now(),
56            end_date: None,
57            is_primary_contact,
58            created_at: Utc::now(),
59            updated_at: Utc::now(),
60        })
61    }
62
63    /// Create a new UnitOwner with a specific start date
64    pub fn new_with_start_date(
65        unit_id: Uuid,
66        owner_id: Uuid,
67        ownership_percentage: Decimal,
68        is_primary_contact: bool,
69        start_date: DateTime<Utc>,
70    ) -> Result<Self, String> {
71        if ownership_percentage <= Decimal::ZERO || ownership_percentage > Decimal::ONE {
72            return Err("Ownership percentage must be between 0 and 1".to_string());
73        }
74
75        Ok(Self {
76            id: Uuid::new_v4(),
77            unit_id,
78            owner_id,
79            ownership_percentage,
80            start_date,
81            end_date: None,
82            is_primary_contact,
83            created_at: Utc::now(),
84            updated_at: Utc::now(),
85        })
86    }
87
88    /// Check if this ownership is currently active
89    pub fn is_active(&self) -> bool {
90        self.end_date.is_none()
91    }
92
93    /// End this ownership relationship
94    pub fn end_ownership(&mut self, end_date: DateTime<Utc>) -> Result<(), String> {
95        if end_date <= self.start_date {
96            return Err("End date must be after start date".to_string());
97        }
98
99        self.end_date = Some(end_date);
100        self.updated_at = Utc::now();
101        Ok(())
102    }
103
104    /// Update ownership percentage
105    pub fn update_percentage(&mut self, new_percentage: Decimal) -> Result<(), String> {
106        if new_percentage <= Decimal::ZERO || new_percentage > Decimal::ONE {
107            return Err("Ownership percentage must be between 0 and 1".to_string());
108        }
109
110        self.ownership_percentage = new_percentage;
111        self.updated_at = Utc::now();
112        Ok(())
113    }
114
115    /// Set as primary contact
116    pub fn set_primary_contact(&mut self, is_primary: bool) {
117        self.is_primary_contact = is_primary;
118        self.updated_at = Utc::now();
119    }
120}
121
122// ============================================================================
123// Story H17 (Track H, CL3) — Représentant de vote / suspension (Art. 3.87 §1).
124//
125// Un lot peut appartenir à plusieurs titulaires (indivision) OU être démembré
126// (usufruit/nue-propriété, emphytéose, superficie). Dans ce cas le droit de
127// vote est SUSPENDU jusqu'à désignation d'un représentant unique (mandataire
128// commun). Logique domaine pure (zéro I/O), consommée par le gate vote (H10)
129// et le recalcul de quorum (H9). Cf. ADR-0011 + spec H17.
130// ============================================================================
131
132/// Nature de la titularité d'une ligne `unit_owners` (Art. 3.87 §1 CC).
133/// Détermine si le lot vote directement ou requiert un représentant unique.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
135pub enum OwnershipType {
136    /// Pleine propriété — titulaire unique, vote direct.
137    #[default]
138    FullOwner,
139    /// Usufruitier (démembrement).
140    Usufruct,
141    /// Nu-propriétaire (démembrement).
142    BareOwner,
143    /// Co-titulaire en indivision.
144    Indivisaire,
145    /// Emphytéote (bail emphytéotique).
146    Emphyteote,
147    /// Superficiaire (droit de superficie).
148    Superficiaire,
149}
150
151impl OwnershipType {
152    /// Vrai uniquement pour la pleine propriété (seul cas votant sans
153    /// représentant désigné quand le lot est mono-titulaire).
154    pub fn is_full_ownership(&self) -> bool {
155        matches!(self, OwnershipType::FullOwner)
156    }
157}
158
159impl std::fmt::Display for OwnershipType {
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        // Valeurs alignées sur le CHECK SQL (migration 20260621000000).
162        let s = match self {
163            OwnershipType::FullOwner => "full_owner",
164            OwnershipType::Usufruct => "usufruct",
165            OwnershipType::BareOwner => "bare_owner",
166            OwnershipType::Indivisaire => "indivisaire",
167            OwnershipType::Emphyteote => "emphyteote",
168            OwnershipType::Superficiaire => "superficiaire",
169        };
170        f.write_str(s)
171    }
172}
173
174impl std::str::FromStr for OwnershipType {
175    type Err = VotingRightError;
176
177    /// Parse strict (mémoire `validate-before-compute`) : toute valeur hors
178    /// enum → erreur typée, jamais un fallback silencieux.
179    fn from_str(s: &str) -> Result<Self, Self::Err> {
180        match s {
181            "full_owner" => Ok(OwnershipType::FullOwner),
182            "usufruct" => Ok(OwnershipType::Usufruct),
183            "bare_owner" => Ok(OwnershipType::BareOwner),
184            "indivisaire" => Ok(OwnershipType::Indivisaire),
185            "emphyteote" => Ok(OwnershipType::Emphyteote),
186            "superficiaire" => Ok(OwnershipType::Superficiaire),
187            other => Err(VotingRightError::UnknownOwnershipType(other.to_string())),
188        }
189    }
190}
191
192/// Statut du droit de vote d'un lot (Art. 3.87 §1 CC).
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum VotingRightStatus {
195    /// Le lot peut voter (mono-plein-propriétaire OU représentant désigné).
196    Active,
197    /// Vote suspendu : lot démembré/indivis sans représentant unique désigné.
198    Suspended,
199}
200
201/// Une ligne de titularité d'un lot, réduite aux attributs pertinents pour le
202/// calcul du droit de vote (Art. 3.87 §1). Value object pur.
203///
204/// Volontairement découplé de `UnitOwner` (persistance) : le gate vote et le
205/// checker quorum n'ont besoin que de `(ownership_type, is_voting_representative)`.
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub struct LotHolder {
208    pub ownership_type: OwnershipType,
209    pub is_voting_representative: bool,
210}
211
212impl LotHolder {
213    pub fn new(ownership_type: OwnershipType, is_voting_representative: bool) -> Self {
214        Self {
215            ownership_type,
216            is_voting_representative,
217        }
218    }
219}
220
221/// Erreurs typées du calcul de droit de vote (Art. 3.87 §1 CC).
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub enum VotingRightError {
224    /// Chaîne de titularité inconnue (hors enum / CHECK DB).
225    UnknownOwnershipType(String),
226    /// Plus d'un représentant de vote désigné pour un même lot. Art. 3.87 §1 :
227    /// les titulaires désignent UN représentant unique.
228    MultipleRepresentatives { unit_id: Uuid, count: usize },
229}
230
231impl std::fmt::Display for VotingRightError {
232    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233        match self {
234            VotingRightError::UnknownOwnershipType(s) => {
235                write!(f, "Type de titularité inconnu : '{}'", s)
236            }
237            VotingRightError::MultipleRepresentatives { unit_id, count } => write!(
238                f,
239                "Lot {} : {} représentants de vote désignés, un seul autorisé (Art. 3.87 §1 CC)",
240                unit_id, count
241            ),
242        }
243    }
244}
245
246impl std::error::Error for VotingRightError {}
247
248/// Droit de vote suspendu (Art. 3.87 §1 CC) : lot démembré/indivis sans
249/// représentant unique désigné. Erreur typée → `AppError` 422
250/// `VOTING_RIGHT_SUSPENDED` (bridge dans `application/error.rs`).
251#[derive(Debug, Clone, PartialEq, Eq)]
252pub struct VotingRightSuspendedError {
253    pub unit_id: Uuid,
254}
255
256impl std::fmt::Display for VotingRightSuspendedError {
257    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258        write!(
259            f,
260            "Droit de vote suspendu pour le lot {} : lot démembré/indivis sans \
261             représentant unique désigné (Art. 3.87 §1 CC)",
262            self.unit_id
263        )
264    }
265}
266
267impl std::error::Error for VotingRightSuspendedError {}
268
269/// Détermine le statut du droit de vote d'un lot à partir de ses titulaires
270/// **actifs** (Art. 3.87 §1 CC). Domaine pur, déterministe.
271///
272/// - Aucun titulaire qualifié (rétro-compat des lots pré-H17) → `Active`
273///   (lot supposé en pleine propriété mono-titulaire).
274/// - Au moins un représentant de vote désigné → `Active` (le mandataire
275///   unique exerce le vote du lot).
276/// - Un seul titulaire en pleine propriété → `Active`.
277/// - Sinon (indivision OU démembrement sans représentant) → `Suspended`.
278pub fn voting_right_status(holders: &[LotHolder]) -> VotingRightStatus {
279    if holders.is_empty() {
280        return VotingRightStatus::Active;
281    }
282    if holders.iter().any(|h| h.is_voting_representative) {
283        return VotingRightStatus::Active;
284    }
285    if holders.len() == 1 && holders[0].ownership_type.is_full_ownership() {
286        return VotingRightStatus::Active;
287    }
288    VotingRightStatus::Suspended
289}
290
291/// Vérifie qu'**au plus un** représentant de vote est désigné pour le lot
292/// (Art. 3.87 §1 : représentant UNIQUE). Erreur typée si ≥ 2 (à appeler lors
293/// de la désignation d'un représentant).
294pub fn assert_single_voting_representative(
295    unit_id: Uuid,
296    holders: &[LotHolder],
297) -> Result<(), VotingRightError> {
298    let count = holders
299        .iter()
300        .filter(|h| h.is_voting_representative)
301        .count();
302    if count >= 2 {
303        return Err(VotingRightError::MultipleRepresentatives { unit_id, count });
304    }
305    Ok(())
306}
307
308/// Garde d'application (gate vote H10/H17) : un lot dont le vote est suspendu
309/// ne peut pas voter (Art. 3.87 §1). Erreur typée → 422 `VOTING_RIGHT_SUSPENDED`.
310pub fn assert_voting_right_active(
311    unit_id: Uuid,
312    holders: &[LotHolder],
313) -> Result<(), VotingRightSuspendedError> {
314    match voting_right_status(holders) {
315        VotingRightStatus::Active => Ok(()),
316        VotingRightStatus::Suspended => Err(VotingRightSuspendedError { unit_id }),
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use rust_decimal_macros::dec;
324
325    #[test]
326    fn test_create_unit_owner() {
327        let unit_id = Uuid::new_v4();
328        let owner_id = Uuid::new_v4();
329
330        let unit_owner = UnitOwner::new(unit_id, owner_id, dec!(0.5), true).unwrap();
331
332        assert_eq!(unit_owner.unit_id, unit_id);
333        assert_eq!(unit_owner.owner_id, owner_id);
334        assert_eq!(unit_owner.ownership_percentage, dec!(0.5));
335        assert!(unit_owner.is_primary_contact);
336        assert!(unit_owner.is_active());
337    }
338
339    #[test]
340    fn test_invalid_ownership_percentage() {
341        let unit_id = Uuid::new_v4();
342        let owner_id = Uuid::new_v4();
343
344        // Test percentage > 1.0
345        let result = UnitOwner::new(unit_id, owner_id, dec!(1.5), false);
346        assert!(result.is_err());
347
348        // Test percentage <= 0
349        let result = UnitOwner::new(unit_id, owner_id, Decimal::ZERO, false);
350        assert!(result.is_err());
351
352        let result = UnitOwner::new(unit_id, owner_id, dec!(-0.5), false);
353        assert!(result.is_err());
354    }
355
356    #[test]
357    fn test_end_ownership() {
358        let unit_id = Uuid::new_v4();
359        let owner_id = Uuid::new_v4();
360
361        let mut unit_owner = UnitOwner::new(unit_id, owner_id, Decimal::ONE, true).unwrap();
362
363        assert!(unit_owner.is_active());
364
365        let end_date = Utc::now() + chrono::Duration::days(1);
366        unit_owner.end_ownership(end_date).unwrap();
367
368        assert!(!unit_owner.is_active());
369        assert_eq!(unit_owner.end_date, Some(end_date));
370    }
371
372    #[test]
373    fn test_invalid_end_date() {
374        let unit_id = Uuid::new_v4();
375        let owner_id = Uuid::new_v4();
376
377        let mut unit_owner = UnitOwner::new(unit_id, owner_id, Decimal::ONE, true).unwrap();
378
379        // End date before start date should fail
380        let invalid_end_date = unit_owner.start_date - chrono::Duration::days(1);
381        let result = unit_owner.end_ownership(invalid_end_date);
382
383        assert!(result.is_err());
384    }
385
386    #[test]
387    fn test_update_percentage() {
388        let unit_id = Uuid::new_v4();
389        let owner_id = Uuid::new_v4();
390
391        let mut unit_owner = UnitOwner::new(unit_id, owner_id, dec!(0.5), true).unwrap();
392
393        unit_owner.update_percentage(dec!(0.75)).unwrap();
394        assert_eq!(unit_owner.ownership_percentage, dec!(0.75));
395
396        // Invalid percentage
397        let result = unit_owner.update_percentage(dec!(1.5));
398        assert!(result.is_err());
399    }
400
401    #[test]
402    fn test_update_percentage_boundary_values() {
403        let unit_id = Uuid::new_v4();
404        let owner_id = Uuid::new_v4();
405
406        let mut unit_owner = UnitOwner::new(unit_id, owner_id, dec!(0.5), false).unwrap();
407
408        // Test boundary: exactly 1.0 (100%) is valid
409        assert!(unit_owner.update_percentage(Decimal::ONE).is_ok());
410        assert_eq!(unit_owner.ownership_percentage, Decimal::ONE);
411
412        // Test boundary: 0.0 is invalid
413        assert!(unit_owner.update_percentage(Decimal::ZERO).is_err());
414
415        // Test boundary: 0.0001 (0.01%) is valid
416        assert!(unit_owner.update_percentage(dec!(0.0001)).is_ok());
417        assert_eq!(unit_owner.ownership_percentage, dec!(0.0001));
418
419        // Test boundary: 1.0001 is invalid
420        assert!(unit_owner.update_percentage(dec!(1.0001)).is_err());
421
422        // Test negative values
423        assert!(unit_owner.update_percentage(dec!(-0.5)).is_err());
424    }
425
426    #[test]
427    fn test_set_primary_contact() {
428        let unit_id = Uuid::new_v4();
429        let owner_id = Uuid::new_v4();
430
431        let mut unit_owner = UnitOwner::new(unit_id, owner_id, dec!(0.5), false).unwrap();
432
433        assert!(!unit_owner.is_primary_contact);
434
435        unit_owner.set_primary_contact(true);
436        assert!(unit_owner.is_primary_contact);
437
438        unit_owner.set_primary_contact(false);
439        assert!(!unit_owner.is_primary_contact);
440    }
441
442    #[test]
443    fn test_ownership_percentage_precision() {
444        let unit_id = Uuid::new_v4();
445        let owner_id = Uuid::new_v4();
446
447        // Test with 4 decimal places (common for co-ownership)
448        let unit_owner = UnitOwner::new(unit_id, owner_id, dec!(0.3333), false).unwrap();
449        assert_eq!(unit_owner.ownership_percentage, dec!(0.3333));
450
451        // Test with very small percentage
452        let unit_owner = UnitOwner::new(unit_id, owner_id, dec!(0.0001), false).unwrap();
453        assert_eq!(unit_owner.ownership_percentage, dec!(0.0001));
454    }
455
456    #[test]
457    fn test_end_ownership_updates_end_date() {
458        let unit_id = Uuid::new_v4();
459        let owner_id = Uuid::new_v4();
460
461        let mut unit_owner = UnitOwner::new(unit_id, owner_id, Decimal::ONE, true).unwrap();
462
463        assert!(unit_owner.end_date.is_none());
464
465        let end_date = Utc::now() + chrono::Duration::days(30);
466        unit_owner.end_ownership(end_date).unwrap();
467
468        assert!(unit_owner.end_date.is_some());
469        assert_eq!(unit_owner.end_date.unwrap(), end_date);
470    }
471
472    #[test]
473    fn test_cannot_end_ownership_twice() {
474        let unit_id = Uuid::new_v4();
475        let owner_id = Uuid::new_v4();
476
477        let mut unit_owner = UnitOwner::new(unit_id, owner_id, Decimal::ONE, true).unwrap();
478
479        let first_end = Utc::now() + chrono::Duration::days(1);
480        unit_owner.end_ownership(first_end).unwrap();
481
482        // Should still work, just updates the date
483        let second_end = Utc::now() + chrono::Duration::days(2);
484        let result = unit_owner.end_ownership(second_end);
485        assert!(result.is_ok());
486        assert_eq!(unit_owner.end_date.unwrap(), second_end);
487    }
488
489    #[test]
490    fn test_timestamps_are_set() {
491        let unit_id = Uuid::new_v4();
492        let owner_id = Uuid::new_v4();
493
494        let before = Utc::now();
495        let unit_owner = UnitOwner::new(unit_id, owner_id, dec!(0.5), false).unwrap();
496        let after = Utc::now();
497
498        // created_at should be between before and after
499        assert!(unit_owner.created_at >= before);
500        assert!(unit_owner.created_at <= after);
501
502        // updated_at should initially equal created_at (within millisecond precision)
503        let diff = (unit_owner.created_at - unit_owner.updated_at)
504            .num_milliseconds()
505            .abs();
506        assert!(diff < 1);
507    }
508
509    #[test]
510    fn test_updated_at_changes_on_modification() {
511        let unit_id = Uuid::new_v4();
512        let owner_id = Uuid::new_v4();
513
514        let mut unit_owner = UnitOwner::new(unit_id, owner_id, dec!(0.5), false).unwrap();
515        let original_updated_at = unit_owner.updated_at;
516
517        // Wait a tiny bit to ensure timestamp changes
518        std::thread::sleep(std::time::Duration::from_millis(10));
519
520        unit_owner.update_percentage(dec!(0.6)).unwrap();
521        assert!(unit_owner.updated_at > original_updated_at);
522
523        let previous_updated = unit_owner.updated_at;
524        std::thread::sleep(std::time::Duration::from_millis(10));
525
526        unit_owner.set_primary_contact(true);
527        assert!(unit_owner.updated_at > previous_updated);
528    }
529
530    #[test]
531    fn test_100_percent_ownership_is_valid() {
532        let unit_id = Uuid::new_v4();
533        let owner_id = Uuid::new_v4();
534
535        let unit_owner = UnitOwner::new(unit_id, owner_id, Decimal::ONE, true).unwrap();
536        assert_eq!(unit_owner.ownership_percentage, Decimal::ONE);
537    }
538
539    #[test]
540    fn test_multiple_owners_scenario_percentages() {
541        let unit_id = Uuid::new_v4();
542        let owner1_id = Uuid::new_v4();
543        let owner2_id = Uuid::new_v4();
544        let owner3_id = Uuid::new_v4();
545
546        // Scenario: 3 co-owners with 50%, 30%, 20%
547        let owner1 = UnitOwner::new(unit_id, owner1_id, dec!(0.5), true).unwrap();
548        let owner2 = UnitOwner::new(unit_id, owner2_id, dec!(0.3), false).unwrap();
549        let owner3 = UnitOwner::new(unit_id, owner3_id, dec!(0.2), false).unwrap();
550
551        assert_eq!(owner1.ownership_percentage, dec!(0.5));
552        assert_eq!(owner2.ownership_percentage, dec!(0.3));
553        assert_eq!(owner3.ownership_percentage, dec!(0.2));
554
555        // Total should be 1.0 EXACTLY (Decimal — pas IEEE 754).
556        let total =
557            owner1.ownership_percentage + owner2.ownership_percentage + owner3.ownership_percentage;
558        assert_eq!(total, Decimal::ONE);
559    }
560
561    // ------------------------------------------------------------------------
562    // Story H17 (CL3) — Représentant de vote / suspension (Art. 3.87 §1 CC).
563    // TDD 4 catégories : @happy / @edge / @security / @negative.
564    // ------------------------------------------------------------------------
565
566    use std::str::FromStr;
567
568    fn holder(t: OwnershipType, rep: bool) -> LotHolder {
569        LotHolder::new(t, rep)
570    }
571
572    /// @happy — lot mono-plein-propriétaire → vote actif.
573    #[test]
574    fn happy_voting_active_mono_full_owner() {
575        let holders = [holder(OwnershipType::FullOwner, false)];
576        assert_eq!(voting_right_status(&holders), VotingRightStatus::Active);
577        assert!(assert_voting_right_active(Uuid::new_v4(), &holders).is_ok());
578    }
579
580    /// @happy — lot avec représentant de vote désigné → vote actif.
581    #[test]
582    fn happy_voting_active_with_designated_representative() {
583        // Indivision (2 titulaires) mais un représentant désigné → actif.
584        let holders = [
585            holder(OwnershipType::Indivisaire, true),
586            holder(OwnershipType::Indivisaire, false),
587        ];
588        assert_eq!(voting_right_status(&holders), VotingRightStatus::Active);
589        assert!(assert_voting_right_active(Uuid::new_v4(), &holders).is_ok());
590    }
591
592    /// @happy — rétro-compat : aucune titularité qualifiée → actif (lot supposé
593    /// pleine propriété mono-titulaire ; aucune régression de vote pré-H17).
594    #[test]
595    fn happy_voting_active_legacy_no_holders() {
596        assert_eq!(voting_right_status(&[]), VotingRightStatus::Active);
597        assert!(assert_voting_right_active(Uuid::new_v4(), &[]).is_ok());
598    }
599
600    /// @edge — lot démembré usufruit/nue-propriété AVEC représentant désigné
601    /// (ici l'usufruitier) → actif.
602    #[test]
603    fn edge_voting_active_usufruct_with_representative() {
604        let holders = [
605            holder(OwnershipType::Usufruct, true),
606            holder(OwnershipType::BareOwner, false),
607        ];
608        assert_eq!(voting_right_status(&holders), VotingRightStatus::Active);
609    }
610
611    /// @edge — emphytéote/superficiaire seul SANS représentant → suspendu
612    /// (démembrement, pas pleine propriété).
613    #[test]
614    fn edge_voting_suspended_single_dismembered_holder() {
615        for t in [OwnershipType::Emphyteote, OwnershipType::Superficiaire] {
616            let holders = [holder(t, false)];
617            assert_eq!(
618                voting_right_status(&holders),
619                VotingRightStatus::Suspended,
620                "type {t} seul sans représentant doit suspendre le vote"
621            );
622        }
623    }
624
625    /// @edge — round-trip Display ⇄ FromStr aligné sur le CHECK SQL.
626    #[test]
627    fn edge_ownership_type_display_fromstr_roundtrip() {
628        for t in [
629            OwnershipType::FullOwner,
630            OwnershipType::Usufruct,
631            OwnershipType::BareOwner,
632            OwnershipType::Indivisaire,
633            OwnershipType::Emphyteote,
634            OwnershipType::Superficiaire,
635        ] {
636            let s = t.to_string();
637            assert_eq!(OwnershipType::from_str(&s).unwrap(), t);
638        }
639    }
640
641    /// @security — lot indivis SANS représentant → suspendu + gate rejette le
642    /// vote avec erreur typée `VotingRightSuspendedError` (→ 422).
643    #[test]
644    fn security_voting_suspended_indivision_without_representative() {
645        let unit_id = Uuid::new_v4();
646        let holders = [
647            holder(OwnershipType::Indivisaire, false),
648            holder(OwnershipType::Indivisaire, false),
649        ];
650        assert_eq!(voting_right_status(&holders), VotingRightStatus::Suspended);
651        let err = assert_voting_right_active(unit_id, &holders).unwrap_err();
652        assert_eq!(err.unit_id, unit_id);
653    }
654
655    /// @security — lot démembré (usufruit + nue-propriété) SANS représentant →
656    /// suspendu (le contournement « voter quand même » est bloqué).
657    #[test]
658    fn security_voting_suspended_dismembered_without_representative() {
659        let holders = [
660            holder(OwnershipType::Usufruct, false),
661            holder(OwnershipType::BareOwner, false),
662        ];
663        assert_eq!(voting_right_status(&holders), VotingRightStatus::Suspended);
664        assert!(assert_voting_right_active(Uuid::new_v4(), &holders).is_err());
665    }
666
667    /// @negative — désignation de 2 représentants pour un même lot → rejet typé
668    /// (Art. 3.87 §1 : représentant unique), pas de panic.
669    #[test]
670    fn negative_multiple_voting_representatives_rejected() {
671        let unit_id = Uuid::new_v4();
672        let holders = [
673            holder(OwnershipType::Indivisaire, true),
674            holder(OwnershipType::Indivisaire, true),
675        ];
676        let err = assert_single_voting_representative(unit_id, &holders).unwrap_err();
677        match err {
678            VotingRightError::MultipleRepresentatives { unit_id: u, count } => {
679                assert_eq!(u, unit_id);
680                assert_eq!(count, 2);
681            }
682            other => panic!("attendu MultipleRepresentatives, obtenu {other:?}"),
683        }
684        // Un seul représentant (ou zéro) passe.
685        assert!(assert_single_voting_representative(
686            unit_id,
687            &[holder(OwnershipType::Indivisaire, true)]
688        )
689        .is_ok());
690    }
691
692    /// @negative — type de titularité inconnu → erreur typée, jamais un
693    /// fallback silencieux.
694    #[test]
695    fn negative_unknown_ownership_type_rejected() {
696        let err = OwnershipType::from_str("locataire").unwrap_err();
697        assert_eq!(
698            err,
699            VotingRightError::UnknownOwnershipType("locataire".to_string())
700        );
701    }
702}