1use 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#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum AcpCaller {
29 SuperAdmin,
31 Admin { organization_id: Uuid },
34 Syndic { organization_id: Uuid },
37 Owner { user_id: Uuid },
39}
40
41impl AcpCaller {
42 pub fn can_mutate(&self) -> bool {
45 matches!(self, AcpCaller::SuperAdmin | AcpCaller::Admin { .. })
46 }
47
48 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 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 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 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 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 )?; 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 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 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 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 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 quota_sum: m.quota_sum.to_string(),
189 })
190 .collect())
191 }
192
193 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 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 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 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 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 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 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 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 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#[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 #[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 #[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 #[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 #[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 AppError::NotFound(_) => {}
650 other => panic!("expected NotFound, got {:?}", other),
651 }
652 }
653
654 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 #[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 #[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 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 #[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 #[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}