Skip to main content

koprogo_api/application/use_cases/
acp_use_cases.rs

1//! ACP use-cases — Story 1.1.
2//!
3//! 5 use-cases : `create`, `get`, `list`, `update`, `archive`.
4//!
5//! Permissions :
6//! - `create` / `update` / `archive` : admin (superadmin OR admin role).
7//! - `list` / `get` : tout authentifié (filtré par scope rôle).
8//!
9//! Tous les retours sont typés `Result<T, AppError>` (CRITICAL §4).
10//!
11//! L'audit est consigné via `infrastructure::audit::AuditLogEntry` côté
12//! handler (pattern existant — cf. `building_handlers.rs`). Ce use-case
13//! reste pur logique métier + permission.
14
15use crate::application::dto::{AcpAvecMetriquesDto, AcpResponseDto, CreateAcpDto, UpdateAcpDto};
16use crate::application::error::AppError;
17use crate::application::ports::{AcpRepository, ListScope, OrganizationRepository};
18use crate::domain::entities::Acp;
19use std::sync::Arc;
20use uuid::Uuid;
21
22/// Rôle effectif de l'appelant pour les permissions ACP.
23///
24/// Mappé depuis `AuthenticatedUser.role` côté handler. Le mapping vit ici
25/// (et pas dans `web::middleware`) pour rester testable en pur Rust sans
26/// AppState.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum AcpCaller {
29    /// SuperAdmin SaaS — accès global, peut tout faire.
30    SuperAdmin,
31    /// Admin métier d'un cabinet syndic — peut CRUD sur les ACPs de son
32    /// cabinet.
33    Admin { organization_id: Uuid },
34    /// Syndic — lecture seule des ACPs de son cabinet (Story 1.1 ; les
35    /// permissions write s'étoffent en stories suivantes).
36    Syndic { organization_id: Uuid },
37    /// Owner — lecture seule des ACPs où il a un rôle assigné.
38    Owner { user_id: Uuid },
39}
40
41impl AcpCaller {
42    /// L'appelant a-t-il le droit de créer/mettre à jour/archiver une ACP ?
43    /// Story 1.1 : seul `SuperAdmin` ou `Admin` (admin métier cabinet).
44    pub fn can_mutate(&self) -> bool {
45        matches!(self, AcpCaller::SuperAdmin | AcpCaller::Admin { .. })
46    }
47
48    /// Scope de listing par rôle. Le SuperAdmin voit tout.
49    pub fn list_scope(&self) -> ListScope {
50        match self {
51            AcpCaller::SuperAdmin => ListScope::All,
52            AcpCaller::Admin { organization_id } | AcpCaller::Syndic { organization_id } => {
53                ListScope::Organization(*organization_id)
54            }
55            AcpCaller::Owner { user_id } => ListScope::Owner(*user_id),
56        }
57    }
58}
59
60pub struct AcpUseCases {
61    repository: Arc<dyn AcpRepository>,
62    organization_repository: Arc<dyn OrganizationRepository>,
63}
64
65impl AcpUseCases {
66    pub fn new(
67        repository: Arc<dyn AcpRepository>,
68        organization_repository: Arc<dyn OrganizationRepository>,
69    ) -> Self {
70        Self {
71            repository,
72            organization_repository,
73        }
74    }
75
76    /// Crée une ACP. Admin only.
77    pub async fn create_acp(
78        &self,
79        caller: &AcpCaller,
80        dto: CreateAcpDto,
81    ) -> Result<AcpResponseDto, AppError> {
82        if !caller.can_mutate() {
83            return Err(AppError::Forbidden(
84                "Only admin can create ACPs".to_string(),
85            ));
86        }
87
88        // Parse organization_id si fourni, et vérifie qu'il existe en DB.
89        let org_id = match dto.organization_id.as_deref() {
90            Some(s) if !s.is_empty() => {
91                let parsed = Uuid::parse_str(s).map_err(|_| {
92                    AppError::Validation("Invalid organization_id format".to_string())
93                })?;
94                // Vérification d'existence : pattern story 1.1 §AC @negative.
95                let exists = self
96                    .organization_repository
97                    .find_by_id(parsed)
98                    .await
99                    .map_err(AppError::from)?
100                    .is_some();
101                if !exists {
102                    return Err(AppError::Validation(format!(
103                        "Organization {} does not exist",
104                        parsed
105                    )));
106                }
107                Some(parsed)
108            }
109            _ => None,
110        };
111
112        // Admin métier ne peut créer une ACP que dans son propre cabinet
113        // (ou auto-gérée). Le SuperAdmin peut tout.
114        if let AcpCaller::Admin { organization_id } = caller {
115            if let Some(target) = org_id {
116                if target != *organization_id {
117                    return Err(AppError::Forbidden(
118                        "Admin can only create ACPs within their own organization".to_string(),
119                    ));
120                }
121            }
122        }
123
124        let acp = Acp::new(
125            org_id,
126            dto.name,
127            dto.address_street,
128            dto.address_postal_code,
129            dto.address_city,
130            dto.bce_number,
131        )?; // AcpError -> AppError::Validation via From impl
132
133        // Track H CL1 — acte de base (défaut 1000 si non fourni). ADR-0010.
134        let acp = match dto.total_tantiemes {
135            Some(tt) => acp.with_total_tantiemes(tt)?,
136            None => acp,
137        };
138
139        let created = self.repository.create(&acp).await?;
140        Ok(Self::to_response_dto(&created))
141    }
142
143    /// Récupère une ACP sans scope check (usage interne — scope_guard helper #603).
144    /// Retourne `Ok(None)` si l'ACP n'existe pas.
145    pub async fn find_acp(&self, acp_id: Uuid) -> Result<Option<Acp>, AppError> {
146        self.repository.find_by_id(acp_id).await
147    }
148
149    /// Récupère une ACP par id, avec scope guard.
150    pub async fn get_acp(&self, caller: &AcpCaller, id: Uuid) -> Result<AcpResponseDto, AppError> {
151        let acp = self
152            .repository
153            .find_by_id(id)
154            .await?
155            .ok_or_else(|| AppError::NotFound(format!("ACP {} not found", id)))?;
156
157        Self::assert_scope(caller, &acp)?;
158        Ok(Self::to_response_dto(&acp))
159    }
160
161    /// Liste les ACPs visibles pour l'appelant.
162    pub async fn list_acps(&self, caller: &AcpCaller) -> Result<Vec<AcpResponseDto>, AppError> {
163        let scope = caller.list_scope();
164        let acps = self.repository.list(scope).await?;
165        Ok(acps.iter().map(Self::to_response_dto).collect())
166    }
167
168    /// Les ACP du périmètre, **avec leurs métriques**.
169    ///
170    /// Sert la table « Mes ACP » du tableau de bord syndic : blocs, lots
171    /// encodés et déclarés, somme des quotités. Le cloisonnement est celui de
172    /// `list_acps` — `caller.list_scope()` —, donc un syndic ne voit que les
173    /// ACP de son cabinet et un copropriétaire que les siennes.
174    pub async fn list_acps_with_metrics(
175        &self,
176        caller: &AcpCaller,
177    ) -> Result<Vec<AcpAvecMetriquesDto>, AppError> {
178        let scope = caller.list_scope();
179        let avec_metriques = self.repository.list_with_metrics(scope).await?;
180        Ok(avec_metriques
181            .iter()
182            .map(|(acp, m)| AcpAvecMetriquesDto {
183                acp: Self::to_response_dto(acp),
184                buildings_count: m.buildings_count,
185                units_count: m.units_count,
186                declared_units_total: m.declared_units_total,
187                // Chaîne, pas flottant : une quotité est opposable.
188                quota_sum: m.quota_sum.to_string(),
189            })
190            .collect())
191    }
192
193    /// Met à jour une ACP. Admin only.
194    pub async fn update_acp(
195        &self,
196        caller: &AcpCaller,
197        id: Uuid,
198        dto: UpdateAcpDto,
199    ) -> Result<AcpResponseDto, AppError> {
200        if !caller.can_mutate() {
201            return Err(AppError::Forbidden(
202                "Only admin can update ACPs".to_string(),
203            ));
204        }
205
206        let mut acp = self
207            .repository
208            .find_by_id(id)
209            .await?
210            .ok_or_else(|| AppError::NotFound(format!("ACP {} not found", id)))?;
211
212        Self::assert_scope(caller, &acp)?;
213
214        // Si on demande explicitement à changer l'organization_id.
215        if let Some(opt_str) = dto.organization_id {
216            let new_org_id = match opt_str {
217                Some(s) if !s.is_empty() => {
218                    let parsed = Uuid::parse_str(&s).map_err(|_| {
219                        AppError::Validation("Invalid organization_id format".to_string())
220                    })?;
221                    let exists = self
222                        .organization_repository
223                        .find_by_id(parsed)
224                        .await
225                        .map_err(AppError::from)?
226                        .is_some();
227                    if !exists {
228                        return Err(AppError::Validation(format!(
229                            "Organization {} does not exist",
230                            parsed
231                        )));
232                    }
233                    Some(parsed)
234                }
235                _ => None,
236            };
237
238            // Admin métier ne peut pas déplacer une ACP vers un autre cabinet.
239            if let AcpCaller::Admin { organization_id } = caller {
240                if let Some(t) = new_org_id {
241                    if t != *organization_id {
242                        return Err(AppError::Forbidden(
243                            "Admin cannot move ACP to a different organization".to_string(),
244                        ));
245                    }
246                }
247            }
248
249            acp.set_organization(new_org_id);
250        }
251
252        acp.update_info(
253            dto.name,
254            dto.address_street,
255            dto.address_postal_code,
256            dto.address_city,
257            dto.bce_number,
258        )?;
259
260        // Track H CL1 — acte de base modifiable (si fourni). ADR-0010.
261        if let Some(tt) = dto.total_tantiemes {
262            acp.set_total_tantiemes(tt)?;
263        }
264
265        let updated = self.repository.update(&acp).await?;
266        Ok(Self::to_response_dto(&updated))
267    }
268
269    /// Archive (=DELETE physique en v0.1.0) une ACP. Admin only.
270    pub async fn archive_acp(&self, caller: &AcpCaller, id: Uuid) -> Result<(), AppError> {
271        if !caller.can_mutate() {
272            return Err(AppError::Forbidden(
273                "Only admin can archive ACPs".to_string(),
274            ));
275        }
276        let acp = self
277            .repository
278            .find_by_id(id)
279            .await?
280            .ok_or_else(|| AppError::NotFound(format!("ACP {} not found", id)))?;
281        Self::assert_scope(caller, &acp)?;
282
283        // Refuser explicitement plutôt que de laisser la base trancher.
284        //
285        // `archive` fait un `DELETE FROM acps` et `buildings.acp_id` référence
286        // `acps(id)` en `NO ACTION` (migration 20260601020000), colonne
287        // `NOT NULL` depuis 20260601040000. Sans ce contrôle, supprimer une ACP
288        // qui porte au moins un immeuble — le cas normal — remonte une
289        // violation de clé étrangère en `AppError::Database`, donc un **500**,
290        // pour ce qui est une règle métier parfaitement prévisible.
291        //
292        // Le port `count_buildings` existait pour ça depuis l'origine mais
293        // renvoyait `Ok(0)` en dur et n'était appelé nulle part.
294        let buildings = self.repository.count_buildings(id).await?;
295        if buildings > 0 {
296            return Err(AppError::Conflict(format!(
297                "ACP {} carries {} building(s) and cannot be archived; detach or delete them first",
298                id, buildings
299            )));
300        }
301
302        self.repository.archive(id).await
303    }
304
305    /// Public scope-check used by Story 1.3 `scope_guard` middleware.
306    /// Loads the ACP by id then delegates to `assert_scope`.
307    /// Refuses with `AcpNotInScope` if the ACP does not exist (no
308    /// resource-existence leak across cabinets).
309    pub async fn assert_can_see_acp(
310        &self,
311        caller: &AcpCaller,
312        acp_id: Uuid,
313    ) -> Result<(), AppError> {
314        let acp = self
315            .repository
316            .find_by_id(acp_id)
317            .await?
318            .ok_or(AppError::AcpNotInScope { acp_id })?;
319        Self::assert_scope(caller, &acp)
320    }
321
322    /// Vérifie que l'appelant a le droit de voir cette ACP précise.
323    /// Centralise la logique pour `get` / `update` / `archive` — un seul
324    /// chemin = un seul test à maintenir (cf. mémoire `audit-to-issue-first`).
325    fn assert_scope(caller: &AcpCaller, acp: &Acp) -> Result<(), AppError> {
326        match caller {
327            AcpCaller::SuperAdmin => Ok(()),
328            AcpCaller::Admin { organization_id } | AcpCaller::Syndic { organization_id } => {
329                match acp.organization_id {
330                    Some(org) if org == *organization_id => Ok(()),
331                    _ => Err(AppError::AcpNotInScope { acp_id: acp.id }),
332                }
333            }
334            // Owner scope : Story 1.1 — on délègue la vérification fine
335            // (UserRoleAssignment) au repository `list`. Pour `get`, on
336            // refuse par défaut sauf si l'ACP est ressortie de `list` —
337            // ici on refuse car on n'a pas la table user_role_assignment
338            // sur l'ACP encore. Story 1.3 enrichira.
339            AcpCaller::Owner { .. } => Err(AppError::AcpNotInScope { acp_id: acp.id }),
340        }
341    }
342
343    fn to_response_dto(acp: &Acp) -> AcpResponseDto {
344        AcpResponseDto {
345            id: acp.id.to_string(),
346            organization_id: acp.organization_id.map(|u| u.to_string()),
347            name: acp.name.clone(),
348            slug: acp.slug.clone(),
349            legal_status: acp.legal_status.as_db_str().to_string(),
350            total_tantiemes: acp.total_tantiemes,
351            bce_number: acp.bce_number.clone(),
352            address_street: acp.address_street.clone(),
353            address_postal_code: acp.address_postal_code.clone(),
354            address_city: acp.address_city.clone(),
355            created_at: acp.created_at.to_rfc3339(),
356            updated_at: acp.updated_at.to_rfc3339(),
357        }
358    }
359}
360
361// ============================================================================
362// Tests — taxonomie 4-cat avec mocks (CRITICAL.md §3).
363// ============================================================================
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use crate::application::ports::{AcpRepository, ListScope, OrganizationRepository};
369    use crate::domain::entities::{Acp, Organization, SubscriptionPlan};
370    use async_trait::async_trait;
371    use mockall::mock;
372
373    mock! {
374        AcpRepo {}
375
376        #[async_trait]
377        impl AcpRepository for AcpRepo {
378            async fn create(&self, acp: &Acp) -> Result<Acp, AppError>;
379            async fn find_by_id(&self, id: Uuid) -> Result<Option<Acp>, AppError>;
380            async fn find_by_id_with_metrics(&self, id: Uuid) -> Result<Option<(Acp, crate::domain::entities::AcpMetrics)>, AppError>;
381            async fn list(&self, scope: ListScope) -> Result<Vec<Acp>, AppError>;
382            async fn list_with_metrics(
383                &self,
384                scope: ListScope,
385            ) -> Result<Vec<(Acp, crate::domain::entities::AcpMetrics)>, AppError>;
386            async fn update(&self, acp: &Acp) -> Result<Acp, AppError>;
387            async fn archive(&self, id: Uuid) -> Result<(), AppError>;
388            async fn count_buildings(&self, id: Uuid) -> Result<i64, AppError>;
389        }
390    }
391
392    mock! {
393        OrgRepo {}
394
395        #[async_trait]
396        impl OrganizationRepository for OrgRepo {
397            async fn create(&self, org: &Organization) -> Result<Organization, String>;
398            async fn find_by_id(&self, id: Uuid) -> Result<Option<Organization>, String>;
399            async fn find_by_slug(&self, slug: &str) -> Result<Option<Organization>, String>;
400            async fn find_all(&self) -> Result<Vec<Organization>, String>;
401            async fn find_page(
402                &self,
403                recherche: Option<String>,
404                limit: i64,
405                offset: i64,
406            ) -> Result<Vec<Organization>, String>;
407            async fn count_matching(&self, recherche: Option<String>) -> Result<i64, String>;
408            async fn update(&self, org: &Organization) -> Result<Organization, String>;
409            async fn delete(&self, id: Uuid) -> Result<bool, String>;
410            async fn count_buildings(&self, org_id: Uuid) -> Result<i64, String>;
411        }
412    }
413
414    fn make_dto(name: &str, org_id: Option<Uuid>) -> CreateAcpDto {
415        CreateAcpDto {
416            organization_id: org_id.map(|u| u.to_string()),
417            name: name.to_string(),
418            address_street: "Rue X 1".to_string(),
419            address_postal_code: "1000".to_string(),
420            address_city: "Bruxelles".to_string(),
421            bce_number: None,
422            total_tantiemes: None,
423        }
424    }
425
426    fn make_org(id: Uuid) -> Organization {
427        let mut o = Organization::new(
428            "Test Cabinet".to_string(),
429            "test@cabinet.be".to_string(),
430            None,
431            SubscriptionPlan::Starter,
432        )
433        .expect("valid org");
434        o.id = id;
435        o
436    }
437
438    // ----- @happy --------------------------------------------------------------
439
440    #[tokio::test]
441    async fn happy_admin_creates_acp_with_organization() {
442        let org_id = Uuid::new_v4();
443        let mut acp_repo = MockAcpRepo::new();
444        let mut org_repo = MockOrgRepo::new();
445
446        org_repo
447            .expect_find_by_id()
448            .returning(move |id| Ok(Some(make_org(id))));
449        acp_repo.expect_create().returning(|a| Ok(a.clone()));
450
451        let uc = AcpUseCases::new(Arc::new(acp_repo), Arc::new(org_repo));
452        let dto = make_dto("Residence Maury", Some(org_id));
453        let res = uc.create_acp(&AcpCaller::SuperAdmin, dto).await;
454
455        assert!(res.is_ok(), "expected Ok, got {:?}", res);
456        let resp = res.unwrap();
457        assert_eq!(resp.name, "Residence Maury");
458        assert_eq!(resp.organization_id, Some(org_id.to_string()));
459    }
460
461    #[tokio::test]
462    async fn happy_admin_creates_self_managed_acp() {
463        let mut acp_repo = MockAcpRepo::new();
464        let org_repo = MockOrgRepo::new();
465        acp_repo.expect_create().returning(|a| Ok(a.clone()));
466
467        let uc = AcpUseCases::new(Arc::new(acp_repo), Arc::new(org_repo));
468        let dto = make_dto("Autogeree", None);
469        let resp = uc
470            .create_acp(&AcpCaller::SuperAdmin, dto)
471            .await
472            .expect("ok");
473        assert!(resp.organization_id.is_none());
474    }
475
476    // ----- @edge --------------------------------------------------------------
477
478    #[tokio::test]
479    async fn edge_create_with_empty_org_id_string_is_treated_as_none() {
480        let mut acp_repo = MockAcpRepo::new();
481        let org_repo = MockOrgRepo::new();
482        acp_repo.expect_create().returning(|a| Ok(a.clone()));
483
484        let uc = AcpUseCases::new(Arc::new(acp_repo), Arc::new(org_repo));
485        let dto = CreateAcpDto {
486            organization_id: Some("".to_string()),
487            name: "Edge Acp".to_string(),
488            address_street: "Rue X 1".to_string(),
489            address_postal_code: "1000".to_string(),
490            address_city: "Bruxelles".to_string(),
491            bce_number: None,
492            total_tantiemes: None,
493        };
494        let resp = uc.create_acp(&AcpCaller::SuperAdmin, dto).await.unwrap();
495        assert!(resp.organization_id.is_none());
496    }
497
498    // ----- @security ----------------------------------------------------------
499
500    #[tokio::test]
501    async fn security_non_admin_cannot_create_acp() {
502        let acp_repo = MockAcpRepo::new();
503        let org_repo = MockOrgRepo::new();
504        let uc = AcpUseCases::new(Arc::new(acp_repo), Arc::new(org_repo));
505        let dto = make_dto("X", None);
506        let err = uc
507            .create_acp(
508                &AcpCaller::Syndic {
509                    organization_id: Uuid::new_v4(),
510                },
511                dto,
512            )
513            .await
514            .unwrap_err();
515        match err {
516            AppError::Forbidden(_) => {}
517            other => panic!("expected Forbidden, got {:?}", other),
518        }
519    }
520
521    #[tokio::test]
522    async fn security_syndic_cabinet_b_cannot_read_acp_of_cabinet_a() {
523        let cabinet_a = Uuid::new_v4();
524        let cabinet_b = Uuid::new_v4();
525
526        let acp = Acp::new(
527            Some(cabinet_a),
528            "Acp A".to_string(),
529            "Rue X 1".to_string(),
530            "1000".to_string(),
531            "Bruxelles".to_string(),
532            None,
533        )
534        .unwrap();
535        let acp_id = acp.id;
536
537        let mut acp_repo = MockAcpRepo::new();
538        let org_repo = MockOrgRepo::new();
539        acp_repo
540            .expect_find_by_id()
541            .returning(move |_| Ok(Some(acp.clone())));
542
543        let uc = AcpUseCases::new(Arc::new(acp_repo), Arc::new(org_repo));
544        let err = uc
545            .get_acp(
546                &AcpCaller::Syndic {
547                    organization_id: cabinet_b,
548                },
549                acp_id,
550            )
551            .await
552            .unwrap_err();
553
554        match err {
555            AppError::AcpNotInScope { acp_id: a } => assert_eq!(a, acp_id),
556            other => panic!("expected AcpNotInScope, got {:?}", other),
557        }
558    }
559
560    #[tokio::test]
561    async fn security_admin_cannot_create_acp_in_different_cabinet() {
562        let own_cabinet = Uuid::new_v4();
563        let other_cabinet = Uuid::new_v4();
564
565        let mut org_repo = MockOrgRepo::new();
566        org_repo
567            .expect_find_by_id()
568            .returning(move |id| Ok(Some(make_org(id))));
569        let acp_repo = MockAcpRepo::new();
570        let uc = AcpUseCases::new(Arc::new(acp_repo), Arc::new(org_repo));
571
572        let dto = make_dto("Forbidden Acp", Some(other_cabinet));
573        let err = uc
574            .create_acp(
575                &AcpCaller::Admin {
576                    organization_id: own_cabinet,
577                },
578                dto,
579            )
580            .await
581            .unwrap_err();
582        match err {
583            AppError::Forbidden(_) => {}
584            other => panic!("expected Forbidden, got {:?}", other),
585        }
586    }
587
588    // ----- @negative ----------------------------------------------------------
589
590    #[tokio::test]
591    async fn negative_create_with_unknown_organization_returns_validation() {
592        let mut org_repo = MockOrgRepo::new();
593        org_repo.expect_find_by_id().returning(|_| Ok(None));
594        let acp_repo = MockAcpRepo::new();
595        let uc = AcpUseCases::new(Arc::new(acp_repo), Arc::new(org_repo));
596
597        let dto = make_dto("Phantom", Some(Uuid::new_v4()));
598        let err = uc
599            .create_acp(&AcpCaller::SuperAdmin, dto)
600            .await
601            .unwrap_err();
602        match err {
603            AppError::Validation(_) => {}
604            other => panic!("expected Validation, got {:?}", other),
605        }
606    }
607
608    #[tokio::test]
609    async fn negative_get_unknown_id_returns_not_found() {
610        let mut acp_repo = MockAcpRepo::new();
611        let org_repo = MockOrgRepo::new();
612        acp_repo.expect_find_by_id().returning(|_| Ok(None));
613        let uc = AcpUseCases::new(Arc::new(acp_repo), Arc::new(org_repo));
614
615        let err = uc
616            .get_acp(&AcpCaller::SuperAdmin, Uuid::new_v4())
617            .await
618            .unwrap_err();
619        match err {
620            AppError::NotFound(_) => {}
621            other => panic!("expected NotFound, got {:?}", other),
622        }
623    }
624
625    #[tokio::test]
626    async fn negative_update_unknown_id_returns_not_found() {
627        let mut acp_repo = MockAcpRepo::new();
628        let org_repo = MockOrgRepo::new();
629        acp_repo.expect_find_by_id().returning(|_| Ok(None));
630        let uc = AcpUseCases::new(Arc::new(acp_repo), Arc::new(org_repo));
631
632        let dto = UpdateAcpDto {
633            organization_id: None,
634            name: "X".to_string(),
635            address_street: "Rue X".to_string(),
636            address_postal_code: "1000".to_string(),
637            address_city: "Bruxelles".to_string(),
638            bce_number: None,
639            total_tantiemes: None,
640        };
641        let err = uc
642            .update_acp(&AcpCaller::SuperAdmin, Uuid::new_v4(), dto)
643            .await
644            .unwrap_err();
645        match err {
646            // "X" trop court → AppError::Validation prend le dessus si on a
647            // déjà la ressource ; sinon NotFound. find_by_id renvoie None →
648            // NotFound est prioritaire.
649            AppError::NotFound(_) => {}
650            other => panic!("expected NotFound, got {:?}", other),
651        }
652    }
653
654    // ----- archivage : garde-fou immeubles rattachés (4-cat) ---------------
655
656    fn make_acp(org_id: Option<Uuid>) -> Acp {
657        Acp::new(
658            org_id,
659            "Residence du Test".to_string(),
660            "Rue X 1".to_string(),
661            "1000".to_string(),
662            "Bruxelles".to_string(),
663            None,
664        )
665        .expect("acp valide")
666    }
667
668    /// @happy — une ACP sans immeuble s'archive.
669    #[tokio::test]
670    async fn happy_archive_acp_without_buildings_succeeds() {
671        let acp = make_acp(None);
672        let acp_id = acp.id;
673        let mut acp_repo = MockAcpRepo::new();
674
675        acp_repo
676            .expect_find_by_id()
677            .returning(move |_| Ok(Some(make_acp(None))));
678        acp_repo.expect_count_buildings().returning(|_| Ok(0));
679        acp_repo.expect_archive().returning(|_| Ok(()));
680
681        let uc = AcpUseCases::new(Arc::new(acp_repo), Arc::new(MockOrgRepo::new()));
682
683        assert!(uc.archive_acp(&AcpCaller::SuperAdmin, acp_id).await.is_ok());
684    }
685
686    /// @negative — une ACP qui porte des immeubles est refusée en **409**, pas
687    /// en 500.
688    ///
689    /// Avant ce garde-fou, `archive` lançait un `DELETE FROM acps` nu contre
690    /// une clé étrangère en `NO ACTION` : la base levait une violation, mappée
691    /// en `AppError::Database`, donc un 500 Internal Server Error pour une
692    /// règle métier parfaitement prévisible.
693    #[tokio::test]
694    async fn negative_archive_acp_with_buildings_returns_conflict() {
695        let acp_id = make_acp(None).id;
696        let mut acp_repo = MockAcpRepo::new();
697
698        acp_repo
699            .expect_find_by_id()
700            .returning(move |_| Ok(Some(make_acp(None))));
701        acp_repo.expect_count_buildings().returning(|_| Ok(3));
702        // L'archivage ne doit JAMAIS être tenté dans ce cas.
703        acp_repo.expect_archive().never();
704
705        let uc = AcpUseCases::new(Arc::new(acp_repo), Arc::new(MockOrgRepo::new()));
706        let err = uc
707            .archive_acp(&AcpCaller::SuperAdmin, acp_id)
708            .await
709            .unwrap_err();
710
711        match err {
712            AppError::Conflict(msg) => {
713                assert!(msg.contains('3'), "le message doit citer le nombre : {msg}");
714            }
715            other => panic!("expected Conflict, got {:?}", other),
716        }
717    }
718
719    /// @edge — la borne est à zéro : un seul immeuble suffit à refuser.
720    #[tokio::test]
721    async fn edge_archive_acp_with_one_building_is_refused() {
722        let acp_id = make_acp(None).id;
723        let mut acp_repo = MockAcpRepo::new();
724
725        acp_repo
726            .expect_find_by_id()
727            .returning(move |_| Ok(Some(make_acp(None))));
728        acp_repo.expect_count_buildings().returning(|_| Ok(1));
729        acp_repo.expect_archive().never();
730
731        let uc = AcpUseCases::new(Arc::new(acp_repo), Arc::new(MockOrgRepo::new()));
732
733        assert!(matches!(
734            uc.archive_acp(&AcpCaller::SuperAdmin, acp_id).await,
735            Err(AppError::Conflict(_))
736        ));
737    }
738
739    /// @security — le contrôle de droits passe AVANT le comptage : un syndic
740    /// ne doit pas pouvoir sonder l'existence d'immeubles via ce chemin.
741    #[tokio::test]
742    async fn security_syndic_archive_is_refused_before_counting() {
743        let mut acp_repo = MockAcpRepo::new();
744        acp_repo.expect_find_by_id().never();
745        acp_repo.expect_count_buildings().never();
746        acp_repo.expect_archive().never();
747
748        let uc = AcpUseCases::new(Arc::new(acp_repo), Arc::new(MockOrgRepo::new()));
749        let err = uc
750            .archive_acp(
751                &AcpCaller::Syndic {
752                    organization_id: Uuid::new_v4(),
753                },
754                Uuid::new_v4(),
755            )
756            .await
757            .unwrap_err();
758
759        assert!(matches!(err, AppError::Forbidden(_)), "got {:?}", err);
760    }
761}