Skip to main content

koprogo_api/application/use_cases/
contractor_report_use_cases.rs

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
16/// Durée de validité du magic link (72 heures)
17const 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    /// #835 — émission/résolution des liens magiques unifiés (scope
24    /// `ContractorReport`). `None` seulement en test unitaire pur (mocks qui
25    /// n'exercent pas `generate_magic_link`).
26    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    /// #835 — branche le système générique de liens magiques (absorption du
50    /// second système qui stockait un token brut sur `contractor_reports`).
51    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    /// Crée un nouveau rapport de travaux (B16-1)
57    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    /// Récupère un rapport par son ID (vérification organisation)
75    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    /// Récupère un rapport via magic token (accès PWA sans auth)
93    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    /// Liste les rapports d'un bâtiment
110    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    /// Liste les rapports d'un ticket
124    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    /// Met à jour le brouillon du rapport (photos, pièces, compte-rendu)
138    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    /// Corps de métier soumet le rapport pour validation CdC (B16-3)
184    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    /// Accès via magic link : soumet le rapport sans authentification classique
204    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    /// CdC valide le rapport → paiement automatique déclenché (B16-6)
223    ///
224    /// Dans une implémentation complète, on ferait appel au PaymentUseCases ici.
225    /// Pour l'instant on retourne le rapport validé et on documente le hook.
226    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        // B16-6: Trigger automatic payment if quote_id is present
245        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                    // A quote that was never submitted (Requested, no price
251                    // data) has nothing to pay — skip the auto-payment.
252                    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                            // Paiement SORTANT vers un prestataire : il ne
268                            // solde aucune quote-part de coproprietaire.
269                            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                        // Fire-and-forget: payment creation failure should not block report validation
286                        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    /// CdC demande des corrections au corps de métier
298    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    /// CdC rejette le rapport
319    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    /// Génère un lien magique pour l'accès PWA corps de métier (B16-2).
341    ///
342    /// #835 — délègue au système générique (`MagicLinkUseCases`, scope
343    /// `ContractorReport`) au lieu d'écrire un token brut dans
344    /// `contractor_reports.magic_token_hash`. Le prestataire reçoit désormais
345    /// UN lien `/c?t=...`, celui que sait déjà lire `MagicLinkContractorPage`,
346    /// au lieu d'un second format (`/contractor/?token=...`) menant à une
347    /// page distincte. Cf. issue #835 — deux systèmes de liens magiques
348    /// parallèles pour un même chantier.
349    ///
350    /// `subject_user_id` : le prestataire n'a souvent PAS de compte (#815 —
351    /// la voie nominale est le lien, pas le compte). `contractor_user_id` est
352    /// alors `None` et on utilise `Uuid::nil()`, un sentinel accepté par
353    /// `MagicLink::issue` (seule contrainte : différer de `issued_by`) — le
354    /// token brut, pas l'identité du sujet, est ce qui autorise l'accès ici.
355    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    /// Lecture du rapport pour l'écran unifié lien magique (#835). L'autorisation
398    /// est déjà assurée en amont par la résolution du jeton (scope cloisonné,
399    /// cf. `MagicLinkUseCases::ensure_scope`) — pas de filtre organisation ici,
400    /// l'appelant est anonyme par nature (c'est le principe même du lien).
401    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    /// Applique les champs du brouillon puis soumet, en un seul aller-retour —
415    /// c'est l'action « respond » du lien magique unifié (#835). Contrairement
416    /// à `submit_by_token` (système B, retiré), les champs soumis (compte-rendu,
417    /// date, pièces) sont réellement persistés avant la transition d'état :
418    /// l'ancien endpoint les ignorait silencieusement (le corps de la requête
419    /// n'était jamais lu), ce qui rendait la soumission systématiquement en
420    /// échec faute de `compte_rendu` déjà enregistré par ailleurs.
421    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    /// Supprime un rapport (Draft seulement)
461    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        // Test the review workflow by submitting then validating (which accepts Submitted state)
596        let org_id = Uuid::new_v4();
597        let mut report = make_draft_report(org_id);
598        // Pre-set to Submitted state to test review path
599        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        // #835 — la génération délègue désormais au système générique de
650        // liens magiques et produit une URL `/c?t=...`, celle que sait déjà
651        // rendre `MagicLinkContractorPage`, au lieu de `/contractor/?token=`
652        // (second système, absorbé).
653        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    // ========================================================================
684    // #835 — liens magiques unifiés (scope ContractorReport)
685    // ========================================================================
686
687    /// Dépôt en mémoire pour `MagicLinkRepository`, suffisant pour exercer
688    /// `generate_magic_link` / `get_via_magic_link` / `respond_via_magic_link`
689    /// sans DB réelle.
690    #[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    // ---- @happy --------------------------------------------------------------
731
732    #[tokio::test]
733    async fn happy_generate_magic_link_uses_nil_subject_when_contractor_has_no_account() {
734        // #815 — le prestataire n'a souvent PAS de compte : contractor_user_id
735        // est None et ne doit pas empêcher l'émission.
736        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; // le brouillon n'a encore rien — tout vient du respond
778        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    // ---- @edge -----------------------------------------------------------------
804
805    #[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; // état terminal
810        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    // ---- @security ---------------------------------------------------------------
831
832    #[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    // ---- @negative ---------------------------------------------------------------
860
861    #[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        // Pas de `.with_magic_link_support(...)` — configuration incomplète.
873        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}