Skip to main content

koprogo_api/domain/copropriete/
building.rs

1use chrono::{DateTime, Utc};
2use rust_decimal::Decimal;
3#[cfg(test)]
4use rust_decimal_macros::dec;
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8/// Métriques agrégées d'un immeuble (calculées par le repository via
9/// `LEFT JOIN units` + `COUNT(*)` + `SUM(quota::NUMERIC)`).
10///
11/// Volontairement **non stockées** dans la table `buildings` (avoid stale state)
12/// — recalculées à chaque lecture. Story 1.4 / FR23.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct BuildingMetrics {
15    /// Nombre réel de `units` rattachées au building (COUNT(*) côté repo).
16    pub units_count: i32,
17    /// Somme exacte des quotas (SUM(quota::NUMERIC) — Decimal strict, jamais f64).
18    pub quota_sum: Decimal,
19}
20
21impl BuildingMetrics {
22    /// Métriques vides (utile pour les builds sans units).
23    pub fn empty() -> Self {
24        Self {
25            units_count: 0,
26            quota_sum: Decimal::ZERO,
27        }
28    }
29}
30
31/// Représente un immeuble en copropriété
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
33pub struct Building {
34    pub id: Uuid,
35    /// Story 1.2 — FK vers `acps.id` (anciennement `organization_id`).
36    /// La migration 20260601040000 a DROP la colonne `organization_id` ;
37    /// le scoping org se fait désormais via `acps.organization_id`.
38    pub acp_id: Uuid,
39    pub name: String,
40    pub address: String,
41    pub city: String,
42    pub postal_code: String,
43    pub country: String,
44    pub total_units: i32,
45    pub total_tantiemes: i32,
46    pub construction_year: Option<i32>,
47
48    // Public syndic information (Belgian legal requirement - Issue #92)
49    pub syndic_name: Option<String>,
50    pub syndic_email: Option<String>,
51    pub syndic_phone: Option<String>,
52    pub syndic_address: Option<String>,
53    pub syndic_office_hours: Option<String>,
54    pub syndic_emergency_contact: Option<String>,
55    pub slug: Option<String>,
56
57    pub created_at: DateTime<Utc>,
58    pub updated_at: DateTime<Utc>,
59}
60
61impl Building {
62    #[allow(clippy::too_many_arguments)]
63    pub fn new(
64        acp_id: Uuid,
65        name: String,
66        address: String,
67        city: String,
68        postal_code: String,
69        country: String,
70        total_units: i32,
71        total_tantiemes: i32,
72        construction_year: Option<i32>,
73    ) -> Result<Self, String> {
74        if name.is_empty() {
75            return Err("Building name cannot be empty".to_string());
76        }
77        if total_units <= 0 {
78            return Err("Total units must be greater than 0".to_string());
79        }
80        if total_tantiemes <= 0 {
81            return Err("Total tantiemes must be greater than 0".to_string());
82        }
83
84        let now = Utc::now();
85        let slug = Self::generate_slug(&name, &address, &city);
86
87        Ok(Self {
88            id: Uuid::new_v4(),
89            acp_id,
90            name,
91            address,
92            city,
93            postal_code,
94            country,
95            total_units,
96            total_tantiemes,
97            construction_year,
98            syndic_name: None,
99            syndic_email: None,
100            syndic_phone: None,
101            syndic_address: None,
102            syndic_office_hours: None,
103            syndic_emergency_contact: None,
104            slug: Some(slug),
105            created_at: now,
106            updated_at: now,
107        })
108    }
109
110    #[allow(clippy::too_many_arguments)]
111    pub fn update_info(
112        &mut self,
113        name: String,
114        address: String,
115        city: String,
116        postal_code: String,
117        country: String,
118        total_units: i32,
119        total_tantiemes: i32,
120        construction_year: Option<i32>,
121    ) {
122        self.name = name.clone();
123        self.address = address.clone();
124        self.city = city.clone();
125        self.postal_code = postal_code;
126        self.country = country;
127        self.total_units = total_units;
128        self.total_tantiemes = total_tantiemes;
129        self.construction_year = construction_year;
130
131        // Regenerate slug if name, address, or city changed
132        self.slug = Some(Self::generate_slug(&name, &address, &city));
133
134        self.updated_at = Utc::now();
135    }
136
137    /// Update syndic public information (Belgian legal requirement)
138    #[allow(clippy::too_many_arguments)]
139    pub fn update_syndic_info(
140        &mut self,
141        syndic_name: Option<String>,
142        syndic_email: Option<String>,
143        syndic_phone: Option<String>,
144        syndic_address: Option<String>,
145        syndic_office_hours: Option<String>,
146        syndic_emergency_contact: Option<String>,
147    ) {
148        self.syndic_name = syndic_name;
149        self.syndic_email = syndic_email;
150        self.syndic_phone = syndic_phone;
151        self.syndic_address = syndic_address;
152        self.syndic_office_hours = syndic_office_hours;
153        self.syndic_emergency_contact = syndic_emergency_contact;
154        self.updated_at = Utc::now();
155    }
156
157    /// Generate SEO-friendly slug from building name, address, and city
158    /// Example: "Residence Les Jardins, 123 Rue de la Paix, Paris" -> "residence-les-jardins-paris"
159    fn generate_slug(name: &str, _address: &str, city: &str) -> String {
160        let combined = format!("{} {}", name, city);
161
162        combined
163            .chars()
164            .map(|c| {
165                // Remove accents and special characters BEFORE lowercase
166                match c {
167                    'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'à' | 'á' | 'â' | 'ã' | 'ä' => 'a',
168                    'È' | 'É' | 'Ê' | 'Ë' | 'è' | 'é' | 'ê' | 'ë' => 'e',
169                    'Ì' | 'Í' | 'Î' | 'Ï' | 'ì' | 'í' | 'î' | 'ï' => 'i',
170                    'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' | 'ò' | 'ó' | 'ô' | 'õ' | 'ö' => 'o',
171                    'Ù' | 'Ú' | 'Û' | 'Ü' | 'ù' | 'ú' | 'û' | 'ü' => 'u',
172                    'Ç' | 'ç' => 'c',
173                    'Ñ' | 'ñ' => 'n',
174                    _ if c.is_alphanumeric() => c.to_ascii_lowercase(),
175                    _ if c.is_whitespace() || c == '-' => '-',
176                    _ => '-',
177                }
178            })
179            .collect::<String>()
180            .split('-')
181            .filter(|s| !s.is_empty())
182            .collect::<Vec<&str>>()
183            .join("-")
184    }
185
186    /// Check if building has public syndic information available
187    pub fn has_public_syndic_info(&self) -> bool {
188        self.syndic_name.is_some() || self.syndic_email.is_some() || self.syndic_phone.is_some()
189    }
190
191    // ========================================================================
192    // Story 1.4 + Track H Story H1 — Conformité immeuble (FR11, FR12, INV-1,
193    // INV-H1).
194    //
195    // Règle (mémoire `admin-publishes-conform-buildings` + `validate-before-compute`) :
196    // `is_conformant := count(units) == total_units && SUM(units.quota) == total_tantiemes`
197    //
198    // **BUG FIX Story H1** : la cible n'est PLUS la constante `dec!(1000)`,
199    // elle est `self.total_tantiemes` (acte de base — 1000 / 10000 / autre).
200    // Les immeubles dont l'acte définit 10000 (lots fractionnés) étaient
201    // précédemment classifiés non-conformes à tort.
202    //
203    // Pureté hexagonale : ces méthodes n'utilisent que des valeurs primitives
204    // (`Decimal`, `i32`) — pas de sqlx, pas d'I/O. Les métriques arrivent via
205    // `BuildingMetrics` calculé dans le repository.
206    // ========================================================================
207
208    /// Méthode d'instance : l'immeuble est-il conformant, étant donné les
209    /// métriques agrégées (count units + SUM quotas) ?
210    ///
211    /// Strict Decimal — aucune tolérance d'arrondi (cf. ADR-0007 + mémoire
212    /// `no-f64-in-money`). Un immeuble à 999/1000 millièmes est non-conformant.
213    /// La cible est l'**acte de base** (`self.total_tantiemes`) — pas une
214    /// constante (bug fix Story H1).
215    pub fn is_conformant(&self, metrics: &BuildingMetrics) -> bool {
216        Self::compute_is_conformant(self.total_units, self.total_tantiemes, metrics)
217    }
218
219    /// Variante statique (pour tests purs sans instancier Building).
220    ///
221    /// **Story H1** : `total_tantiemes` est paramètre (acte de base de
222    /// l'immeuble, 1000 / 10000 / autre) — plus de constante hard-codée.
223    ///
224    /// `declared_units` n'entre plus dans le verdict : la conformité ne porte
225    /// que sur les **quotités**, seul axe que l'acte de base fixe et que le
226    /// registre légal enregistre (Art. 3.85 § 1er al. 2). Le paramètre est
227    /// conservé pour ne pas casser les appelants, et parce que l'écart de lots
228    /// reste rapporté par `assert_conformant` — il renseigne sans bloquer.
229    ///
230    /// Voir la note détaillée sur `Acp::is_conformant` et l'issue #770.
231    pub fn compute_is_conformant(
232        _declared_units: i32,
233        total_tantiemes: i32,
234        metrics: &BuildingMetrics,
235    ) -> bool {
236        metrics.quota_sum == Decimal::from(total_tantiemes)
237    }
238
239    /// Delta des quotas vs acte de base (positif = manque, négatif = surplus).
240    /// Utilisé par la fiche immeuble pour afficher un message explicite à
241    /// l'utilisateur (FR11).
242    ///
243    /// **Story H1** : méthode d'instance qui lit `self.total_tantiemes` —
244    /// plus de constante hard-codée. Convention :
245    /// `quota_delta = total_tantiemes - quota_sum`. Un drift de 2.5 sur
246    /// acte 1000 → +2.5 (manque). Un surplus de 50 sur acte 10000 → -50.
247    pub fn quota_delta(&self, metrics: &BuildingMetrics) -> Decimal {
248        Decimal::from(self.total_tantiemes) - metrics.quota_sum
249    }
250
251    /// Assertion typée (Track H Story H1) — retourne `Err(BuildingNotConformantError)`
252    /// si l'immeuble n'est pas conforme. Erreur exploitable par les use-cases
253    /// (validate-before-compute) et le frontend (toast 422 narratif).
254    pub fn assert_conformant(
255        &self,
256        metrics: &BuildingMetrics,
257    ) -> Result<(), BuildingNotConformantError> {
258        if !self.is_conformant(metrics) {
259            return Err(BuildingNotConformantError {
260                building_id: self.id,
261                units_delta: self.total_units - metrics.units_count,
262                quota_delta: self.quota_delta(metrics),
263                quota_basis: self.total_tantiemes,
264            });
265        }
266        Ok(())
267    }
268
269    /// Validate unit shares distribution according to Art. 577-2 §4 Code Civil belge.
270    /// Sum of unit shares must equal building total_shares (typically 1000 ou 10000
271    /// millièmes). Returns Ok(()) if valid, Err if invalid or excessive.
272    ///
273    /// **Story H1** : `total_tantiemes` est paramètre (acte de base) au lieu
274    /// d'être hard-codé à 1000.
275    ///
276    /// Belgian legal requirement: All units' shares must sum to the building's total_shares
277    /// to ensure proper copropriété governance and voting/cost allocation.
278    pub fn validate_unit_shares_distribution(
279        units: &[crate::domain::entities::Unit],
280        total_tantiemes: i32,
281    ) -> Result<(), String> {
282        // Quotas en millièmes — Decimal exact, conversion via .trunc() vers i32 pour la borne.
283        use rust_decimal::prelude::ToPrimitive;
284        let total_shares_decimal: rust_decimal::Decimal = units.iter().map(|u| u.quota).sum();
285        let total_shares: i32 = total_shares_decimal.trunc().to_i32().unwrap_or(0);
286
287        // Note: During setup, units may not sum to total_shares yet (incomplete distribution is OK)
288        // Full validation happens at building completion/first AG
289        // However, we can warn if distribution is excessive vs acte de base.
290        if total_shares > total_tantiemes {
291            return Err(format!(
292                "Total unit shares ({}) exceeds acte de base ({}) (Art. 577-2 §4 CC). \
293                 Sum of all unit quotas cannot exceed building total_tantiemes.",
294                total_shares, total_tantiemes
295            ));
296        }
297
298        Ok(())
299    }
300}
301
302/// Track H Story H1 — Erreur typée pour la validation conformité d'un
303/// immeuble (INV-H1).
304///
305/// Exposée par `Building::assert_conformant()`. Mappée vers `AppError::BuildingNotConformant`
306/// (HTTP 422 + payload `BUILDING_NOT_CONFORMANT`) par `From<>` dans
307/// `application/error.rs`.
308///
309/// Convention `quota_delta` : `total_tantiemes - quota_sum`. Positif si
310/// l'immeuble manque de quotas (cas typique drift), négatif si surplus.
311/// `quota_basis` = acte de base (1000, 10000, autre) — exposé au FE pour
312/// affichage explicite (« 25 / 10000 »).
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub struct BuildingNotConformantError {
315    pub building_id: Uuid,
316    pub units_delta: i32,
317    pub quota_delta: Decimal,
318    pub quota_basis: i32,
319}
320
321impl std::fmt::Display for BuildingNotConformantError {
322    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
323        write!(
324            f,
325            "Building {} not conformant: {} units missing, quota delta {} / {} (acte de base)",
326            self.building_id, self.units_delta, self.quota_delta, self.quota_basis
327        )
328    }
329}
330
331impl std::error::Error for BuildingNotConformantError {}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn test_create_building_success() {
339        let acp_id = Uuid::new_v4();
340        let building = Building::new(
341            acp_id,
342            "Résidence Les Jardins".to_string(),
343            "123 Rue de la Paix".to_string(),
344            "Paris".to_string(),
345            "75001".to_string(),
346            "France".to_string(),
347            50,
348            1000,
349            Some(1985),
350        );
351
352        assert!(building.is_ok());
353        let building = building.unwrap();
354        assert_eq!(building.acp_id, acp_id);
355        assert_eq!(building.name, "Résidence Les Jardins");
356        assert_eq!(building.total_units, 50);
357        assert_eq!(building.total_tantiemes, 1000);
358    }
359
360    #[test]
361    fn test_create_building_empty_name_fails() {
362        let acp_id = Uuid::new_v4();
363        let building = Building::new(
364            acp_id,
365            "".to_string(),
366            "123 Rue de la Paix".to_string(),
367            "Paris".to_string(),
368            "75001".to_string(),
369            "France".to_string(),
370            50,
371            1000,
372            Some(1985),
373        );
374
375        assert!(building.is_err());
376        assert_eq!(building.unwrap_err(), "Building name cannot be empty");
377    }
378
379    #[test]
380    fn test_create_building_zero_units_fails() {
381        let acp_id = Uuid::new_v4();
382        let building = Building::new(
383            acp_id,
384            "Résidence Les Jardins".to_string(),
385            "123 Rue de la Paix".to_string(),
386            "Paris".to_string(),
387            "75001".to_string(),
388            "France".to_string(),
389            0,
390            1000,
391            Some(1985),
392        );
393
394        assert!(building.is_err());
395        assert_eq!(building.unwrap_err(), "Total units must be greater than 0");
396    }
397
398    // ========================================================================
399    // Story 1.4 — Tests `is_conformant` 4-cat (CRITICAL §3).
400    // ========================================================================
401
402    fn make_building(total_units: i32) -> Building {
403        Building::new(
404            Uuid::new_v4(),
405            "Test".to_string(),
406            "Rue Test 1".to_string(),
407            "Bruxelles".to_string(),
408            "1000".to_string(),
409            "Belgium".to_string(),
410            total_units,
411            1000,
412            None,
413        )
414        .unwrap()
415    }
416
417    #[test]
418    fn happy_is_conformant_when_count_matches_and_quota_1000() {
419        let b = make_building(2);
420        let metrics = BuildingMetrics {
421            units_count: 2,
422            quota_sum: dec!(1000),
423        };
424        assert!(b.is_conformant(&metrics));
425        assert_eq!(b.quota_delta(&metrics), dec!(0));
426    }
427
428    #[test]
429    fn edge_is_not_conformant_when_quota_off_by_one_millieme() {
430        let b = make_building(2);
431        let metrics = BuildingMetrics {
432            units_count: 2,
433            quota_sum: dec!(999),
434        };
435        assert!(!b.is_conformant(&metrics), "no rounding tolerance");
436        // Track H Story H1 convention : quota_delta = total_tantiemes - quota_sum
437        // Manque 1 millième → delta +1 (positif = manque).
438        assert_eq!(b.quota_delta(&metrics), dec!(1));
439    }
440
441    /// Un écart de lots seul ne rend plus l'immeuble non conforme (#770).
442    #[test]
443    fn edge_is_conformant_meme_si_le_compte_de_lots_diverge() {
444        let b = make_building(3);
445        let metrics = BuildingMetrics {
446            units_count: 2,
447            quota_sum: dec!(1000),
448        };
449        assert!(
450            b.is_conformant(&metrics),
451            "les quotités totalisent l'acte : le compte de lots déclaré ne doit \
452             plus fermer la comptabilité (#770)"
453        );
454    }
455
456    /// L'écart de quotités reste bloquant.
457    #[test]
458    fn negative_is_not_conformant_quand_les_quotites_manquent() {
459        let b = make_building(3);
460        let metrics = BuildingMetrics {
461            units_count: 3,
462            quota_sum: dec!(999),
463        };
464        assert!(!b.is_conformant(&metrics));
465    }
466
467    #[test]
468    fn edge_empty_metrics_returns_zero_not_nan() {
469        let metrics = BuildingMetrics::empty();
470        assert_eq!(metrics.quota_sum, Decimal::ZERO);
471        assert_eq!(metrics.units_count, 0);
472        let b = make_building(1);
473        assert!(!b.is_conformant(&metrics));
474        // Track H Story H1 : building.total_tantiemes (1000) - quota_sum (0) = 1000
475        assert_eq!(b.quota_delta(&metrics), dec!(1000));
476    }
477
478    #[test]
479    fn security_compute_is_conformant_is_pure_no_side_effect() {
480        // Méthode statique : appelable sans Building → pas de fuite d'état.
481        // Track H Story H1 : total_tantiemes passé en param (plus de constante).
482        assert!(Building::compute_is_conformant(
483            3,
484            1000,
485            &BuildingMetrics {
486                units_count: 3,
487                quota_sum: dec!(1000),
488            }
489        ));
490        assert!(!Building::compute_is_conformant(
491            3,
492            1000,
493            &BuildingMetrics {
494                units_count: 3,
495                quota_sum: dec!(1001),
496            }
497        ));
498    }
499
500    #[test]
501    fn negative_quota_delta_for_surplus_is_negative() {
502        // Track H Story H1 convention : delta = total_tantiemes - quota_sum
503        // Surplus quota_sum=1500 vs basis 1000 → delta = -500 (négatif).
504        let metrics = BuildingMetrics {
505            units_count: 2,
506            quota_sum: dec!(1500),
507        };
508        let b = make_building(2);
509        assert_eq!(b.quota_delta(&metrics), dec!(-500));
510    }
511
512    #[test]
513    fn negative_quota_delta_for_deficit_is_positive() {
514        // Track H Story H1 convention : delta = total_tantiemes - quota_sum
515        // Manque quota_sum=900 vs basis 1000 → delta = +100 (positif).
516        let metrics = BuildingMetrics {
517            units_count: 2,
518            quota_sum: dec!(900),
519        };
520        let b = make_building(2);
521        assert_eq!(b.quota_delta(&metrics), dec!(100));
522    }
523
524    #[test]
525    fn test_update_building_info() {
526        let acp_id = Uuid::new_v4();
527        let mut building = Building::new(
528            acp_id,
529            "Old Name".to_string(),
530            "Old Address".to_string(),
531            "Old City".to_string(),
532            "00000".to_string(),
533            "France".to_string(),
534            10,
535            1000,
536            None,
537        )
538        .unwrap();
539
540        let old_updated_at = building.updated_at;
541
542        building.update_info(
543            "New Name".to_string(),
544            "New Address".to_string(),
545            "New City".to_string(),
546            "11111".to_string(),
547            "France".to_string(),
548            10,
549            1500,
550            None,
551        );
552
553        assert_eq!(building.name, "New Name");
554        assert_eq!(building.address, "New Address");
555        assert_eq!(building.total_tantiemes, 1500);
556        assert!(building.updated_at > old_updated_at);
557    }
558}
559
560// ============================================================================
561// Track H Story H1 — Tests `assert_conformant` 4-cat (CRITICAL §3).
562//
563// Couvre **2 actes de base** (1000 et 10000) pour démontrer le bug fix :
564// la cible n'est jamais hard-codée, elle se lit sur `self.total_tantiemes`.
565// ============================================================================
566
567#[cfg(test)]
568mod assert_conformant_tests {
569    use super::*;
570
571    fn make_building_with_basis(total_units: i32, total_tantiemes: i32) -> Building {
572        Building::new(
573            Uuid::new_v4(),
574            format!("Test {}/{}", total_units, total_tantiemes),
575            "Rue Test 1".to_string(),
576            "Bruxelles".to_string(),
577            "1000".to_string(),
578            "Belgium".to_string(),
579            total_units,
580            total_tantiemes,
581            None,
582        )
583        .unwrap()
584    }
585
586    // ----------------------------------------------------------------------
587    // @happy — chemin nominal sur 1000 et 10000 (acte de base)
588    // ----------------------------------------------------------------------
589
590    #[test]
591    fn happy_returns_ok_when_conformant_1000() {
592        // Cas typique millièmes (1000).
593        let b = make_building_with_basis(10, 1000);
594        let metrics = BuildingMetrics {
595            units_count: 10,
596            quota_sum: dec!(1000),
597        };
598        assert!(b.assert_conformant(&metrics).is_ok());
599    }
600
601    #[test]
602    fn happy_returns_ok_when_conformant_10000() {
603        // Bug fix Story H1 — building avec acte de base 10000 (lots
604        // fractionnés finement) doit être conforme s'il l'est réellement.
605        let b = make_building_with_basis(182, 10000);
606        let metrics = BuildingMetrics {
607            units_count: 182,
608            quota_sum: dec!(10000),
609        };
610        assert!(b.assert_conformant(&metrics).is_ok());
611    }
612
613    #[test]
614    fn happy_returns_ok_when_conformant_exotic_500() {
615        // AC-H1.e5 — cas exotique acte ancien à 500 — assertion fonctionne aussi.
616        let b = make_building_with_basis(5, 500);
617        let metrics = BuildingMetrics {
618            units_count: 5,
619            quota_sum: dec!(500),
620        };
621        assert!(b.assert_conformant(&metrics).is_ok());
622    }
623
624    // ----------------------------------------------------------------------
625    // @edge — bornes Decimal strict (1000 ET 10000)
626    // ----------------------------------------------------------------------
627
628    #[test]
629    fn edge_quota_off_by_one_tenth_fails_1000() {
630        // AC-H1.e1 — building 1000, manque 0.1.
631        let b = make_building_with_basis(10, 1000);
632        let metrics = BuildingMetrics {
633            units_count: 10,
634            quota_sum: dec!(999.9),
635        };
636        let err = b.assert_conformant(&metrics).unwrap_err();
637        assert_eq!(err.quota_delta, dec!(0.1));
638        assert_eq!(err.units_delta, 0);
639        assert_eq!(err.quota_basis, 1000);
640    }
641
642    #[test]
643    fn edge_quota_off_by_one_tenth_fails_10000() {
644        // AC-H1.e1bis — building 10000, manque 0.1.
645        let b = make_building_with_basis(182, 10000);
646        let metrics = BuildingMetrics {
647            units_count: 182,
648            quota_sum: dec!(9999.9),
649        };
650        let err = b.assert_conformant(&metrics).unwrap_err();
651        assert_eq!(err.quota_delta, dec!(0.1));
652        assert_eq!(err.units_delta, 0);
653        assert_eq!(err.quota_basis, 10000);
654    }
655
656    /// Quotités justes, compte de lots divergent → **conforme** (#770).
657    ///
658    /// L'ancien AC-H1.e2 exigeait l'inverse. Il encodait une règle qui fermait
659    /// la comptabilité de tout syndic encodant son acte de base lot par lot.
660    #[test]
661    fn edge_units_mismatch_with_quota_correct_passe() {
662        let b = make_building_with_basis(10, 1000);
663        let metrics = BuildingMetrics {
664            units_count: 9,
665            quota_sum: dec!(1000),
666        };
667        assert!(b.assert_conformant(&metrics).is_ok());
668    }
669
670    #[test]
671    fn edge_quota_basis_10000_drift_181_units_975_short() {
672        // Cas immeuble @gilmry — 181 lots sur 182, somme 9975 sur 10000.
673        let b = make_building_with_basis(182, 10000);
674        let metrics = BuildingMetrics {
675            units_count: 181,
676            quota_sum: dec!(9975),
677        };
678        let err = b.assert_conformant(&metrics).unwrap_err();
679        assert_eq!(err.units_delta, 1);
680        assert_eq!(err.quota_delta, dec!(25));
681        assert_eq!(err.quota_basis, 10000);
682    }
683
684    // ----------------------------------------------------------------------
685    // @security — assert_conformant est pur, pas d'I/O ni d'état caché.
686    // ----------------------------------------------------------------------
687
688    #[test]
689    fn security_metrics_tampering_changes_outcome_but_is_pure() {
690        // AC-H1.s1 — Si attaquant remplace metrics (forge), `assert_conformant`
691        // se base sur ces metrics : la pureté est respectée, la responsabilité
692        // de la véracité des metrics revient au repository SQL en amont.
693        // Ce test documente le contrat : pas de trust caché côté domaine.
694        let b = make_building_with_basis(10, 1000);
695        let forged_conformant = BuildingMetrics {
696            units_count: 10,
697            quota_sum: dec!(1000),
698        };
699        assert!(b.assert_conformant(&forged_conformant).is_ok());
700
701        // Les quotités sont le seul axe : c'est un quota_sum tronqué qui
702        // doit être détecté, pas un compte de lots (#770).
703        let forged_non_conformant = BuildingMetrics {
704            units_count: 10,
705            quota_sum: dec!(999),
706        };
707        let err = b.assert_conformant(&forged_non_conformant).unwrap_err();
708        assert_eq!(err.quota_delta, dec!(1));
709        // Calcul reste déterministe, indépendant de tout état externe.
710    }
711
712    #[test]
713    fn security_error_struct_is_debug_safe() {
714        // AC-H1.n2 — Debug derives, peut être logué sans risque (pas de pwd /
715        // pas de token / pas de PII : juste UUID + 2 entiers + 1 Decimal).
716        let err = BuildingNotConformantError {
717            building_id: Uuid::new_v4(),
718            units_delta: 1,
719            quota_delta: dec!(0.5),
720            quota_basis: 1000,
721        };
722        let debug_string = format!("{:?}", err);
723        assert!(debug_string.contains("building_id"));
724        assert!(debug_string.contains("units_delta"));
725        // Pas d'info sensible exposée.
726    }
727
728    // ----------------------------------------------------------------------
729    // @negative — défaillance correcte (pas de panic)
730    // ----------------------------------------------------------------------
731
732    #[test]
733    fn negative_empty_metrics_yields_full_deltas() {
734        // AC-H1.n1 — metrics vides, building total_units=10 basis 1000 → Err
735        // avec units_delta=10, quota_delta=1000.
736        let b = make_building_with_basis(10, 1000);
737        let metrics = BuildingMetrics::empty();
738        let err = b.assert_conformant(&metrics).unwrap_err();
739        assert_eq!(err.units_delta, 10);
740        assert_eq!(err.quota_delta, dec!(1000));
741        assert_eq!(err.quota_basis, 1000);
742    }
743
744    #[test]
745    fn negative_empty_metrics_full_deltas_10000() {
746        // Idem mais avec acte de base 10000.
747        let b = make_building_with_basis(182, 10000);
748        let metrics = BuildingMetrics::empty();
749        let err = b.assert_conformant(&metrics).unwrap_err();
750        assert_eq!(err.units_delta, 182);
751        assert_eq!(err.quota_delta, dec!(10000));
752        assert_eq!(err.quota_basis, 10000);
753    }
754
755    #[test]
756    fn negative_display_format_contains_basis() {
757        // Display impl pour logs — inclut le quota_basis pour audit.
758        let err = BuildingNotConformantError {
759            building_id: Uuid::nil(),
760            units_delta: 1,
761            quota_delta: dec!(25),
762            quota_basis: 10000,
763        };
764        let s = format!("{}", err);
765        assert!(
766            s.contains("10000"),
767            "Display should include quota_basis: {}",
768            s
769        );
770        assert!(
771            s.contains("25"),
772            "Display should include quota_delta: {}",
773            s
774        );
775    }
776}