1use crate::application::dto::contractor_report_dto::{
2 ContractorReportResponseDto, CreateContractorReportDto, MagicLinkResponseDto, RejectReportDto,
3 RequestCorrectionsDto, UpdateContractorReportDto,
4};
5use crate::application::dto::payment_dto::CreatePaymentRequest;
6use crate::application::error::AppError;
7use crate::application::ports::contractor_report_repository::ContractorReportRepository;
8use crate::application::ports::quote_repository::QuoteRepository;
9use crate::application::use_cases::{MagicLinkUseCases, PaymentUseCases};
10use crate::domain::entities::contractor_report::{ContractorReport, ContractorReportStatus};
11use crate::domain::entities::{MagicLinkScopeKind, PaymentMethodType};
12use chrono::{Duration, Utc};
13use std::sync::Arc;
14use uuid::Uuid;
15
16const MAGIC_LINK_VALIDITY_HOURS: i64 = 72;
18
19pub struct ContractorReportUseCases {
20 pub repo: Arc<dyn ContractorReportRepository>,
21 pub quote_repo: Option<Arc<dyn QuoteRepository>>,
22 pub payment_use_cases: Option<Arc<PaymentUseCases>>,
23 pub magic_link_use_cases: Option<Arc<MagicLinkUseCases>>,
27}
28
29impl ContractorReportUseCases {
30 pub fn new(repo: Arc<dyn ContractorReportRepository>) -> Self {
31 Self {
32 repo,
33 quote_repo: None,
34 payment_use_cases: None,
35 magic_link_use_cases: None,
36 }
37 }
38
39 pub fn with_payment_support(
40 mut self,
41 quote_repo: Arc<dyn QuoteRepository>,
42 payment_use_cases: Arc<PaymentUseCases>,
43 ) -> Self {
44 self.quote_repo = Some(quote_repo);
45 self.payment_use_cases = Some(payment_use_cases);
46 self
47 }
48
49 pub fn with_magic_link_support(mut self, magic_link_use_cases: Arc<MagicLinkUseCases>) -> Self {
52 self.magic_link_use_cases = Some(magic_link_use_cases);
53 self
54 }
55
56 pub async fn create(
58 &self,
59 organization_id: Uuid,
60 dto: CreateContractorReportDto,
61 ) -> Result<ContractorReportResponseDto, String> {
62 let report = ContractorReport::new(
63 organization_id,
64 dto.building_id,
65 dto.contractor_name,
66 dto.ticket_id,
67 dto.quote_id,
68 dto.contractor_user_id,
69 )?;
70 let saved = self.repo.create(&report).await?;
71 Ok(ContractorReportResponseDto::from(&saved))
72 }
73
74 pub async fn get(
76 &self,
77 id: Uuid,
78 organization_id: Uuid,
79 ) -> Result<ContractorReportResponseDto, String> {
80 let report = self
81 .repo
82 .find_by_id(id)
83 .await?
84 .ok_or_else(|| format!("Rapport {} introuvable", id))?;
85
86 if report.organization_id != organization_id {
87 return Err("Accès refusé".to_string());
88 }
89 Ok(ContractorReportResponseDto::from(&report))
90 }
91
92 pub async fn get_by_token(
94 &self,
95 token_hash: &str,
96 ) -> Result<ContractorReportResponseDto, String> {
97 let report = self
98 .repo
99 .find_by_magic_token(token_hash)
100 .await?
101 .ok_or("Lien invalide ou expiré".to_string())?;
102
103 if !report.is_magic_token_valid() {
104 return Err("Le lien magic a expiré (validité 72h)".to_string());
105 }
106 Ok(ContractorReportResponseDto::from(&report))
107 }
108
109 pub async fn list_by_building(
111 &self,
112 building_id: Uuid,
113 organization_id: Uuid,
114 ) -> Result<Vec<ContractorReportResponseDto>, String> {
115 let reports = self.repo.find_by_building(building_id).await?;
116 Ok(reports
117 .iter()
118 .filter(|r| r.organization_id == organization_id)
119 .map(ContractorReportResponseDto::from)
120 .collect())
121 }
122
123 pub async fn list_by_ticket(
125 &self,
126 ticket_id: Uuid,
127 organization_id: Uuid,
128 ) -> Result<Vec<ContractorReportResponseDto>, String> {
129 let reports = self.repo.find_by_ticket(ticket_id).await?;
130 Ok(reports
131 .iter()
132 .filter(|r| r.organization_id == organization_id)
133 .map(ContractorReportResponseDto::from)
134 .collect())
135 }
136
137 pub async fn update(
139 &self,
140 id: Uuid,
141 organization_id: Uuid,
142 dto: UpdateContractorReportDto,
143 ) -> Result<ContractorReportResponseDto, String> {
144 let mut report = self
145 .repo
146 .find_by_id(id)
147 .await?
148 .ok_or_else(|| format!("Rapport {} introuvable", id))?;
149
150 if report.organization_id != organization_id {
151 return Err("Accès refusé".to_string());
152 }
153 if report.status != ContractorReportStatus::Draft
154 && report.status != ContractorReportStatus::RequiresCorrection
155 {
156 return Err(format!(
157 "Impossible de modifier depuis l'état {:?}",
158 report.status
159 ));
160 }
161
162 if let Some(date) = dto.work_date {
163 report.work_date = Some(date);
164 }
165 if let Some(cr) = dto.compte_rendu {
166 report.compte_rendu = Some(cr);
167 }
168 if let Some(photos) = dto.photos_before {
169 report.photos_before = photos;
170 }
171 if let Some(photos) = dto.photos_after {
172 report.photos_after = photos;
173 }
174 if let Some(parts) = dto.parts_replaced {
175 report.parts_replaced = parts.into_iter().map(Into::into).collect();
176 }
177 report.updated_at = Utc::now();
178
179 let saved = self.repo.update(&report).await?;
180 Ok(ContractorReportResponseDto::from(&saved))
181 }
182
183 pub async fn submit(
185 &self,
186 id: Uuid,
187 organization_id: Uuid,
188 ) -> Result<ContractorReportResponseDto, String> {
189 let mut report = self
190 .repo
191 .find_by_id(id)
192 .await?
193 .ok_or_else(|| format!("Rapport {} introuvable", id))?;
194
195 if report.organization_id != organization_id {
196 return Err("Accès refusé".to_string());
197 }
198 report.submit()?;
199 let saved = self.repo.update(&report).await?;
200 Ok(ContractorReportResponseDto::from(&saved))
201 }
202
203 pub async fn submit_by_token(
205 &self,
206 token_hash: &str,
207 ) -> Result<ContractorReportResponseDto, String> {
208 let mut report = self
209 .repo
210 .find_by_magic_token(token_hash)
211 .await?
212 .ok_or("Lien invalide ou expiré".to_string())?;
213
214 if !report.is_magic_token_valid() {
215 return Err("Le lien magic a expiré (validité 72h)".to_string());
216 }
217 report.submit()?;
218 let saved = self.repo.update(&report).await?;
219 Ok(ContractorReportResponseDto::from(&saved))
220 }
221
222 pub async fn validate(
227 &self,
228 id: Uuid,
229 organization_id: Uuid,
230 validated_by: Uuid,
231 ) -> Result<ContractorReportResponseDto, String> {
232 let mut report = self
233 .repo
234 .find_by_id(id)
235 .await?
236 .ok_or_else(|| format!("Rapport {} introuvable", id))?;
237
238 if report.organization_id != organization_id {
239 return Err("Accès refusé".to_string());
240 }
241 report.validate(validated_by)?;
242 let saved = self.repo.update(&report).await?;
243
244 if let Some(quote_id) = saved.quote_id {
246 if let (Some(quote_repo), Some(payment_uc)) =
247 (&self.quote_repo, &self.payment_use_cases)
248 {
249 if let Ok(Some(quote)) = quote_repo.find_by_id(quote_id).await {
250 let amount_cents = quote
253 .amount_incl_vat
254 .map(|a| {
255 (a * rust_decimal::Decimal::from(100))
256 .to_string()
257 .parse::<i64>()
258 .unwrap_or(0)
259 })
260 .unwrap_or(0);
261
262 if amount_cents > 0 {
263 let payment_req = CreatePaymentRequest {
264 building_id: quote.building_id,
265 owner_id: quote.contractor_id,
266 expense_id: None,
267 contribution_id: None,
270 amount_cents,
271 payment_method_type: PaymentMethodType::BankTransfer,
272 payment_method_id: None,
273 description: Some(format!(
274 "Paiement prestataire — Rapport #{} validé (Devis {})",
275 saved.id, quote.project_title
276 )),
277 metadata: Some(
278 serde_json::json!({
279 "contractor_report_id": saved.id,
280 "quote_id": quote_id,
281 })
282 .to_string(),
283 ),
284 };
285 let _ = payment_uc
287 .create_payment(organization_id, payment_req)
288 .await;
289 }
290 }
291 }
292 }
293
294 Ok(ContractorReportResponseDto::from(&saved))
295 }
296
297 pub async fn request_corrections(
299 &self,
300 id: Uuid,
301 organization_id: Uuid,
302 dto: RequestCorrectionsDto,
303 ) -> Result<ContractorReportResponseDto, String> {
304 let mut report = self
305 .repo
306 .find_by_id(id)
307 .await?
308 .ok_or_else(|| format!("Rapport {} introuvable", id))?;
309
310 if report.organization_id != organization_id {
311 return Err("Accès refusé".to_string());
312 }
313 report.request_corrections(dto.comments)?;
314 let saved = self.repo.update(&report).await?;
315 Ok(ContractorReportResponseDto::from(&saved))
316 }
317
318 pub async fn reject(
320 &self,
321 id: Uuid,
322 organization_id: Uuid,
323 dto: RejectReportDto,
324 rejected_by: Uuid,
325 ) -> Result<ContractorReportResponseDto, String> {
326 let mut report = self
327 .repo
328 .find_by_id(id)
329 .await?
330 .ok_or_else(|| format!("Rapport {} introuvable", id))?;
331
332 if report.organization_id != organization_id {
333 return Err("Accès refusé".to_string());
334 }
335 report.reject(dto.comments, rejected_by)?;
336 let saved = self.repo.update(&report).await?;
337 Ok(ContractorReportResponseDto::from(&saved))
338 }
339
340 pub async fn generate_magic_link(
356 &self,
357 report_id: Uuid,
358 organization_id: Uuid,
359 issued_by: Uuid,
360 base_url: &str,
361 ) -> Result<MagicLinkResponseDto, AppError> {
362 let report = self
363 .repo
364 .find_by_id(report_id)
365 .await
366 .map_err(AppError::Internal)?
367 .ok_or_else(|| AppError::NotFound(format!("contractor_report {}", report_id)))?;
368
369 if report.organization_id != organization_id {
370 return Err(AppError::Forbidden("Accès refusé".to_string()));
371 }
372
373 let magic_link_use_cases = self
374 .magic_link_use_cases
375 .as_ref()
376 .ok_or_else(|| AppError::Internal("MagicLinkUseCases non configuré".to_string()))?;
377
378 let subject_user_id = report.contractor_user_id.unwrap_or(Uuid::nil());
379 let issued = magic_link_use_cases
380 .issue(
381 subject_user_id,
382 MagicLinkScopeKind::ContractorReport,
383 report_id,
384 issued_by,
385 Duration::hours(MAGIC_LINK_VALIDITY_HOURS).num_seconds(),
386 )
387 .await?;
388
389 let magic_link = format!("{}/c?t={}", base_url.trim_end_matches('/'), issued.token);
390
391 Ok(MagicLinkResponseDto {
392 magic_link,
393 expires_at: issued.expires_at,
394 })
395 }
396
397 pub async fn get_via_magic_link(
402 &self,
403 report_id: Uuid,
404 ) -> Result<ContractorReportResponseDto, AppError> {
405 let report = self
406 .repo
407 .find_by_id(report_id)
408 .await
409 .map_err(AppError::Internal)?
410 .ok_or_else(|| AppError::NotFound(format!("contractor_report {}", report_id)))?;
411 Ok(ContractorReportResponseDto::from(&report))
412 }
413
414 pub async fn respond_via_magic_link(
422 &self,
423 report_id: Uuid,
424 dto: UpdateContractorReportDto,
425 ) -> Result<ContractorReportResponseDto, AppError> {
426 let mut report = self
427 .repo
428 .find_by_id(report_id)
429 .await
430 .map_err(AppError::Internal)?
431 .ok_or_else(|| AppError::NotFound(format!("contractor_report {}", report_id)))?;
432
433 if let Some(date) = dto.work_date {
434 report.work_date = Some(date);
435 }
436 if let Some(cr) = dto.compte_rendu {
437 report.compte_rendu = Some(cr);
438 }
439 if let Some(photos) = dto.photos_before {
440 report.photos_before = photos;
441 }
442 if let Some(photos) = dto.photos_after {
443 report.photos_after = photos;
444 }
445 if let Some(parts) = dto.parts_replaced {
446 report.parts_replaced = parts.into_iter().map(Into::into).collect();
447 }
448 report.updated_at = Utc::now();
449
450 report.submit().map_err(AppError::Validation)?;
451
452 let saved = self
453 .repo
454 .update(&report)
455 .await
456 .map_err(AppError::Internal)?;
457 Ok(ContractorReportResponseDto::from(&saved))
458 }
459
460 pub async fn delete(&self, id: Uuid, organization_id: Uuid) -> Result<(), String> {
462 let report = self
463 .repo
464 .find_by_id(id)
465 .await?
466 .ok_or_else(|| format!("Rapport {} introuvable", id))?;
467
468 if report.organization_id != organization_id {
469 return Err("Accès refusé".to_string());
470 }
471 if report.status != ContractorReportStatus::Draft {
472 return Err(format!(
473 "Seuls les rapports en brouillon peuvent être supprimés (état actuel: {:?})",
474 report.status
475 ));
476 }
477 self.repo.delete(id).await?;
478 Ok(())
479 }
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485 use crate::application::ports::contractor_report_repository::ContractorReportRepository;
486 use async_trait::async_trait;
487 use mockall::mock;
488
489 mock! {
490 ContractorReportRepo {}
491
492 #[async_trait]
493 impl ContractorReportRepository for ContractorReportRepo {
494 async fn create(&self, report: &ContractorReport) -> Result<ContractorReport, String>;
495 async fn find_by_id(&self, id: Uuid) -> Result<Option<ContractorReport>, String>;
496 async fn find_by_magic_token(&self, token_hash: &str) -> Result<Option<ContractorReport>, String>;
497 async fn find_by_ticket(&self, ticket_id: Uuid) -> Result<Vec<ContractorReport>, String>;
498 async fn find_by_quote(&self, quote_id: Uuid) -> Result<Vec<ContractorReport>, String>;
499 async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<ContractorReport>, String>;
500 async fn find_by_organization(&self, organization_id: Uuid) -> Result<Vec<ContractorReport>, String>;
501 async fn update(&self, report: &ContractorReport) -> Result<ContractorReport, String>;
502 async fn delete(&self, id: Uuid) -> Result<bool, String>;
503 }
504 }
505
506 fn make_draft_report(org_id: Uuid) -> ContractorReport {
507 let mut r = ContractorReport::new(
508 org_id,
509 Uuid::new_v4(),
510 "Martin Plomberie SPRL".to_string(),
511 Some(Uuid::new_v4()),
512 None,
513 None,
514 )
515 .unwrap();
516 r.compte_rendu = Some("Travaux effectués conformément au devis".to_string());
517 r
518 }
519
520 #[tokio::test]
521 async fn test_create_report_success() {
522 let org_id = Uuid::new_v4();
523 let building_id = Uuid::new_v4();
524 let ticket_id = Uuid::new_v4();
525
526 let mut mock_repo = MockContractorReportRepo::new();
527 mock_repo.expect_create().returning(|r| Ok(r.clone()));
528
529 let uc = ContractorReportUseCases::new(Arc::new(mock_repo));
530
531 let dto = CreateContractorReportDto {
532 building_id,
533 contractor_name: "Plombier SA".to_string(),
534 ticket_id: Some(ticket_id),
535 quote_id: None,
536 contractor_user_id: None,
537 };
538
539 let result = uc.create(org_id, dto).await;
540 assert!(result.is_ok());
541 let resp = result.unwrap();
542 assert_eq!(resp.organization_id, org_id);
543 assert_eq!(resp.building_id, building_id);
544 assert_eq!(resp.contractor_name, "Plombier SA");
545 assert_eq!(resp.status, "draft");
546 }
547
548 #[tokio::test]
549 async fn test_get_by_token_success() {
550 let org_id = Uuid::new_v4();
551 let mut report = make_draft_report(org_id);
552 let token = "valid-token-hash";
553 report.magic_token_hash = Some(token.to_string());
554 report.magic_token_expires_at = Some(Utc::now() + Duration::hours(24));
555
556 let report_clone = report.clone();
557 let mut mock_repo = MockContractorReportRepo::new();
558 mock_repo
559 .expect_find_by_magic_token()
560 .withf(|t| t == "valid-token-hash")
561 .returning(move |_| Ok(Some(report_clone.clone())));
562
563 let uc = ContractorReportUseCases::new(Arc::new(mock_repo));
564
565 let result = uc.get_by_token(token).await;
566 assert!(result.is_ok());
567 let resp = result.unwrap();
568 assert_eq!(resp.contractor_name, "Martin Plomberie SPRL");
569 }
570
571 #[tokio::test]
572 async fn test_submit_report_success() {
573 let org_id = Uuid::new_v4();
574 let report = make_draft_report(org_id);
575 let report_id = report.id;
576
577 let report_for_find = report.clone();
578 let mut mock_repo = MockContractorReportRepo::new();
579 mock_repo
580 .expect_find_by_id()
581 .withf(move |id| *id == report_id)
582 .returning(move |_| Ok(Some(report_for_find.clone())));
583 mock_repo.expect_update().returning(|r| Ok(r.clone()));
584
585 let uc = ContractorReportUseCases::new(Arc::new(mock_repo));
586
587 let result = uc.submit(report_id, org_id).await;
588 assert!(result.is_ok());
589 let resp = result.unwrap();
590 assert_eq!(resp.status, "submitted");
591 }
592
593 #[tokio::test]
594 async fn test_start_review_via_update() {
595 let org_id = Uuid::new_v4();
597 let mut report = make_draft_report(org_id);
598 report.status = ContractorReportStatus::Submitted;
600 report.submitted_at = Some(Utc::now());
601 let report_id = report.id;
602
603 let report_for_find = report.clone();
604 let mut mock_repo = MockContractorReportRepo::new();
605 mock_repo
606 .expect_find_by_id()
607 .withf(move |id| *id == report_id)
608 .returning(move |_| Ok(Some(report_for_find.clone())));
609 mock_repo.expect_update().returning(|r| Ok(r.clone()));
610
611 let uc = ContractorReportUseCases::new(Arc::new(mock_repo));
612 let validator_id = Uuid::new_v4();
613
614 let result = uc.validate(report_id, org_id, validator_id).await;
615 assert!(result.is_ok());
616 let resp = result.unwrap();
617 assert_eq!(resp.status, "validated");
618 assert_eq!(resp.validated_by, Some(validator_id));
619 }
620
621 #[tokio::test]
622 async fn test_validate_report_success() {
623 let org_id = Uuid::new_v4();
624 let validator_id = Uuid::new_v4();
625 let mut report = make_draft_report(org_id);
626 report.status = ContractorReportStatus::UnderReview;
627 let report_id = report.id;
628
629 let report_for_find = report.clone();
630 let mut mock_repo = MockContractorReportRepo::new();
631 mock_repo
632 .expect_find_by_id()
633 .withf(move |id| *id == report_id)
634 .returning(move |_| Ok(Some(report_for_find.clone())));
635 mock_repo.expect_update().returning(|r| Ok(r.clone()));
636
637 let uc = ContractorReportUseCases::new(Arc::new(mock_repo));
638
639 let result = uc.validate(report_id, org_id, validator_id).await;
640 assert!(result.is_ok());
641 let resp = result.unwrap();
642 assert_eq!(resp.status, "validated");
643 assert!(resp.validated_at.is_some());
644 assert_eq!(resp.validated_by, Some(validator_id));
645 }
646
647 #[tokio::test]
648 async fn test_generate_magic_link_success() {
649 let org_id = Uuid::new_v4();
654 let issued_by = Uuid::new_v4();
655 let report = make_draft_report(org_id);
656 let report_id = report.id;
657
658 let report_for_find = report.clone();
659 let mut mock_repo = MockContractorReportRepo::new();
660 mock_repo
661 .expect_find_by_id()
662 .withf(move |id| *id == report_id)
663 .returning(move |_| Ok(Some(report_for_find.clone())));
664
665 let uc = ContractorReportUseCases::new(Arc::new(mock_repo))
666 .with_magic_link_support(new_magic_link_use_cases());
667
668 let result = uc
669 .generate_magic_link(report_id, org_id, issued_by, "https://app.koprogo.be")
670 .await;
671 assert!(result.is_ok(), "{:?}", result.err());
672 let link_dto = result.unwrap();
673 assert!(
674 link_dto
675 .magic_link
676 .starts_with("https://app.koprogo.be/c?t="),
677 "unexpected magic_link: {}",
678 link_dto.magic_link
679 );
680 assert!(link_dto.expires_at > Utc::now());
681 }
682
683 #[derive(Default)]
691 struct InMemoryMagicLinkRepo {
692 rows: std::sync::Mutex<Vec<crate::domain::entities::MagicLink>>,
693 }
694
695 #[async_trait]
696 impl crate::application::ports::MagicLinkRepository for InMemoryMagicLinkRepo {
697 async fn save(&self, link: &crate::domain::entities::MagicLink) -> Result<(), AppError> {
698 self.rows.lock().unwrap().push(link.clone());
699 Ok(())
700 }
701
702 async fn find_by_token_hash(
703 &self,
704 token_hash: &str,
705 ) -> Result<Option<crate::domain::entities::MagicLink>, AppError> {
706 Ok(self
707 .rows
708 .lock()
709 .unwrap()
710 .iter()
711 .find(|l| l.token_hash == token_hash)
712 .cloned())
713 }
714
715 async fn mark_consumed(&self, id: Uuid) -> Result<(), AppError> {
716 let mut rows = self.rows.lock().unwrap();
717 if let Some(row) = rows.iter_mut().find(|l| l.id == id) {
718 row.consumed_at = Some(Utc::now());
719 }
720 Ok(())
721 }
722 }
723
724 fn new_magic_link_use_cases() -> Arc<MagicLinkUseCases> {
725 let repo: Arc<dyn crate::application::ports::MagicLinkRepository> =
726 Arc::new(InMemoryMagicLinkRepo::default());
727 Arc::new(MagicLinkUseCases::new(repo))
728 }
729
730 #[tokio::test]
733 async fn happy_generate_magic_link_uses_nil_subject_when_contractor_has_no_account() {
734 let org_id = Uuid::new_v4();
737 let issued_by = Uuid::new_v4();
738 let report = make_draft_report(org_id);
739 assert!(report.contractor_user_id.is_none());
740 let report_id = report.id;
741
742 let mut mock_repo = MockContractorReportRepo::new();
743 mock_repo
744 .expect_find_by_id()
745 .returning(move |_| Ok(Some(report.clone())));
746
747 let uc = ContractorReportUseCases::new(Arc::new(mock_repo))
748 .with_magic_link_support(new_magic_link_use_cases());
749
750 let result = uc
751 .generate_magic_link(report_id, org_id, issued_by, "https://app.koprogo.be")
752 .await;
753 assert!(result.is_ok(), "{:?}", result.err());
754 }
755
756 #[tokio::test]
757 async fn happy_get_via_magic_link_returns_report_dto() {
758 let org_id = Uuid::new_v4();
759 let report = make_draft_report(org_id);
760 let report_id = report.id;
761
762 let mut mock_repo = MockContractorReportRepo::new();
763 mock_repo
764 .expect_find_by_id()
765 .returning(move |_| Ok(Some(report.clone())));
766
767 let uc = ContractorReportUseCases::new(Arc::new(mock_repo));
768 let dto = uc.get_via_magic_link(report_id).await.unwrap();
769 assert_eq!(dto.id, report_id);
770 assert_eq!(dto.contractor_name, "Martin Plomberie SPRL");
771 }
772
773 #[tokio::test]
774 async fn happy_respond_via_magic_link_applies_fields_then_submits() {
775 let org_id = Uuid::new_v4();
776 let mut report = make_draft_report(org_id);
777 report.compte_rendu = None; let report_id = report.id;
779
780 let mut mock_repo = MockContractorReportRepo::new();
781 mock_repo
782 .expect_find_by_id()
783 .returning(move |_| Ok(Some(report.clone())));
784 mock_repo.expect_update().returning(|r| Ok(r.clone()));
785
786 let uc = ContractorReportUseCases::new(Arc::new(mock_repo));
787 let dto = UpdateContractorReportDto {
788 work_date: Some(Utc::now()),
789 compte_rendu: Some("Remplacement du joint défectueux.".to_string()),
790 photos_before: None,
791 photos_after: None,
792 parts_replaced: None,
793 };
794
795 let result = uc.respond_via_magic_link(report_id, dto).await.unwrap();
796 assert_eq!(result.status, "submitted");
797 assert_eq!(
798 result.compte_rendu.as_deref(),
799 Some("Remplacement du joint défectueux.")
800 );
801 }
802
803 #[tokio::test]
806 async fn edge_respond_via_magic_link_on_terminal_state_is_rejected() {
807 let org_id = Uuid::new_v4();
808 let mut report = make_draft_report(org_id);
809 report.status = ContractorReportStatus::Validated; let report_id = report.id;
811
812 let mut mock_repo = MockContractorReportRepo::new();
813 mock_repo
814 .expect_find_by_id()
815 .returning(move |_| Ok(Some(report.clone())));
816
817 let uc = ContractorReportUseCases::new(Arc::new(mock_repo));
818 let dto = UpdateContractorReportDto {
819 work_date: None,
820 compte_rendu: Some("Tentative de re-soumission".to_string()),
821 photos_before: None,
822 photos_after: None,
823 parts_replaced: None,
824 };
825
826 let err = uc.respond_via_magic_link(report_id, dto).await.unwrap_err();
827 assert!(matches!(err, AppError::Validation(_)));
828 }
829
830 #[tokio::test]
833 async fn security_generate_magic_link_rejects_foreign_organization() {
834 let org_id = Uuid::new_v4();
835 let other_org_id = Uuid::new_v4();
836 let report = make_draft_report(org_id);
837 let report_id = report.id;
838
839 let mut mock_repo = MockContractorReportRepo::new();
840 mock_repo
841 .expect_find_by_id()
842 .returning(move |_| Ok(Some(report.clone())));
843
844 let uc = ContractorReportUseCases::new(Arc::new(mock_repo))
845 .with_magic_link_support(new_magic_link_use_cases());
846
847 let err = uc
848 .generate_magic_link(
849 report_id,
850 other_org_id,
851 Uuid::new_v4(),
852 "https://app.koprogo.be",
853 )
854 .await
855 .unwrap_err();
856 assert!(matches!(err, AppError::Forbidden(_)));
857 }
858
859 #[tokio::test]
862 async fn negative_generate_magic_link_without_support_configured_is_internal_error() {
863 let org_id = Uuid::new_v4();
864 let report = make_draft_report(org_id);
865 let report_id = report.id;
866
867 let mut mock_repo = MockContractorReportRepo::new();
868 mock_repo
869 .expect_find_by_id()
870 .returning(move |_| Ok(Some(report.clone())));
871
872 let uc = ContractorReportUseCases::new(Arc::new(mock_repo));
874
875 let err = uc
876 .generate_magic_link(report_id, org_id, Uuid::new_v4(), "https://app.koprogo.be")
877 .await
878 .unwrap_err();
879 assert!(matches!(err, AppError::Internal(_)));
880 }
881
882 #[tokio::test]
883 async fn negative_get_via_magic_link_unknown_report_returns_not_found() {
884 let mut mock_repo = MockContractorReportRepo::new();
885 mock_repo.expect_find_by_id().returning(|_| Ok(None));
886
887 let uc = ContractorReportUseCases::new(Arc::new(mock_repo));
888 let err = uc.get_via_magic_link(Uuid::new_v4()).await.unwrap_err();
889 assert!(matches!(err, AppError::NotFound(_)));
890 }
891
892 #[tokio::test]
893 async fn negative_respond_via_magic_link_without_compte_rendu_fails_validation() {
894 let org_id = Uuid::new_v4();
895 let mut report = make_draft_report(org_id);
896 report.compte_rendu = None;
897 let report_id = report.id;
898
899 let mut mock_repo = MockContractorReportRepo::new();
900 mock_repo
901 .expect_find_by_id()
902 .returning(move |_| Ok(Some(report.clone())));
903
904 let uc = ContractorReportUseCases::new(Arc::new(mock_repo));
905 let dto = UpdateContractorReportDto {
906 work_date: None,
907 compte_rendu: None,
908 photos_before: None,
909 photos_after: None,
910 parts_replaced: None,
911 };
912
913 let err = uc.respond_via_magic_link(report_id, dto).await.unwrap_err();
914 assert!(matches!(err, AppError::Validation(_)));
915 }
916}