Skip to main content

koprogo_api/application/use_cases/
contractor_evaluation_use_cases.rs

1//! Use cases for [`ContractorEvaluation`] (Story 3.9 — FR34 FR35 INV-21
2//! INV-24).
3//!
4//! One operation exposed to handlers:
5//! `ContractorEvaluationUseCases::create_evaluation` — a syndic (or a
6//! mandated owner) records an evaluation against a contractor. Guards
7//! enforced here:
8//!
9//! - the referenced `TechnicalSpec` MUST exist (else `AppError::NotFound`);
10//! - it MUST be in `TechnicalSpecStatus::Approved` — a Draft /
11//!   PendingSignatures / Superseded spec does not legitimise an
12//!   evaluation (else `AppError::TechnicalSpecRequired` → 422);
13//! - `ContractorEvaluation::new` validates every structural invariant
14//!   (scores in `[1, 5]`, comment length, no self-evaluation, no
15//!   duplicate linked tickets, no nil UUIDs).
16//!
17//! Append-only behaviour is enforced at the DB trigger level — the use case
18//! exposes no update / delete; the repo trait has no such methods either.
19//!
20//! [`TechnicalSpec`]: crate::domain::entities::TechnicalSpec
21//! [`TechnicalSpecStatus::Approved`]: crate::domain::entities::TechnicalSpecStatus::Approved
22
23use crate::application::error::AppError;
24use crate::application::ports::{ContractorEvaluationRepository, TechnicalSpecRepository};
25use crate::domain::entities::{ContractorEvaluation, EvaluationScores, TechnicalSpecStatus};
26use std::sync::Arc;
27use uuid::Uuid;
28
29/// Use cases container. Holds trait-object handles to the two repositories
30/// (parallel to [`TechnicalSpecUseCases`](super::TechnicalSpecUseCases)) so
31/// AppState can store it as a non-generic `Arc<…>` without forcing every
32/// downstream consumer to carry type parameters.
33pub struct ContractorEvaluationUseCases {
34    repo: Arc<dyn ContractorEvaluationRepository>,
35    tech_spec_repo: Arc<dyn TechnicalSpecRepository>,
36}
37
38impl ContractorEvaluationUseCases {
39    pub fn new(
40        repo: Arc<dyn ContractorEvaluationRepository>,
41        tech_spec_repo: Arc<dyn TechnicalSpecRepository>,
42    ) -> Self {
43        Self {
44            repo,
45            tech_spec_repo,
46        }
47    }
48
49    /// Record a new evaluation. Guards in this order:
50    ///
51    /// 1. The TechnicalSpec MUST exist — else `NotFound`.
52    /// 2. It MUST be in `Approved` status — else `TechnicalSpecRequired` 422.
53    ///    A Draft / PendingSignatures spec means the prestation has not been
54    ///    formally signed off; a Superseded spec means a later version
55    ///    governs the current works and that newer spec is what should
56    ///    legitimise the evaluation.
57    /// 3. The entity constructor enforces every structural invariant
58    ///    (including `evaluator_user_id != contractor_user_id`, INV-21).
59    pub async fn create_evaluation(
60        &self,
61        contractor_user_id: Uuid,
62        technical_spec_id: Uuid,
63        linked_ticket_ids: Vec<Uuid>,
64        evaluator_user_id: Uuid,
65        scores: EvaluationScores,
66        comment: String,
67    ) -> Result<ContractorEvaluation, AppError> {
68        // 1+2. Spec exists AND is Approved.
69        let spec = self
70            .tech_spec_repo
71            .find_by_id(technical_spec_id)
72            .await?
73            .ok_or_else(|| AppError::NotFound(format!("technical_spec {}", technical_spec_id)))?;
74
75        if !matches!(spec.status, TechnicalSpecStatus::Approved) {
76            return Err(AppError::TechnicalSpecRequired);
77        }
78
79        // 3. Structural invariants (typed errors).
80        let evaluation = ContractorEvaluation::new(
81            contractor_user_id,
82            technical_spec_id,
83            linked_ticket_ids,
84            evaluator_user_id,
85            scores,
86            comment,
87        )?;
88
89        self.repo.save(&evaluation).await?;
90        Ok(evaluation)
91    }
92
93    pub async fn get_evaluation(&self, id: Uuid) -> Result<ContractorEvaluation, AppError> {
94        self.repo
95            .find_by_id(id)
96            .await?
97            .ok_or_else(|| AppError::NotFound(format!("contractor_evaluation {}", id)))
98    }
99
100    pub async fn list_for_contractor(
101        &self,
102        contractor_user_id: Uuid,
103    ) -> Result<Vec<ContractorEvaluation>, AppError> {
104        self.repo.list_for_contractor(contractor_user_id).await
105    }
106}
107
108// ============================================================================
109// Tests — taxonomie 4 catégories (CRITICAL.md #3, Story 3.9)
110// ============================================================================
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use crate::domain::entities::{
116        SemVer, SignatoryRole, TechnicalSpec, TechnicalSpecSignature, TechnicalSpecStatus,
117    };
118    use async_trait::async_trait;
119    use chrono::{DateTime, Utc};
120    use std::collections::HashMap;
121    use std::str::FromStr;
122    use std::sync::Mutex;
123
124    // ── Mock ContractorEvaluation repository ───────────────────────────────
125
126    #[derive(Default)]
127    struct InMemoryEvalRepo {
128        rows: Mutex<HashMap<Uuid, ContractorEvaluation>>,
129    }
130
131    #[async_trait]
132    impl ContractorEvaluationRepository for InMemoryEvalRepo {
133        async fn save(&self, e: &ContractorEvaluation) -> Result<(), AppError> {
134            // Idempotent: PK collision is acceptable in a mock (the production
135            // INSERT would fail with 23505, but here we don't simulate that —
136            // the use-case never replays the same id).
137            self.rows.lock().unwrap().insert(e.id, e.clone());
138            Ok(())
139        }
140
141        async fn find_by_id(&self, id: Uuid) -> Result<Option<ContractorEvaluation>, AppError> {
142            Ok(self.rows.lock().unwrap().get(&id).cloned())
143        }
144
145        async fn list_for_contractor(
146            &self,
147            contractor_user_id: Uuid,
148        ) -> Result<Vec<ContractorEvaluation>, AppError> {
149            let rows = self.rows.lock().unwrap();
150            let mut out: Vec<ContractorEvaluation> = rows
151                .values()
152                .filter(|e| e.contractor_user_id == contractor_user_id)
153                .cloned()
154                .collect();
155            out.sort_by_key(|e| std::cmp::Reverse(e.created_at));
156            Ok(out)
157        }
158    }
159
160    // ── Mock TechnicalSpec repository ──────────────────────────────────────
161
162    #[derive(Default)]
163    struct InMemoryTechSpecRepo {
164        specs: Mutex<HashMap<Uuid, TechnicalSpec>>,
165        signatures: Mutex<Vec<TechnicalSpecSignature>>,
166    }
167
168    #[async_trait]
169    impl TechnicalSpecRepository for InMemoryTechSpecRepo {
170        async fn save(&self, spec: &TechnicalSpec) -> Result<(), AppError> {
171            self.specs.lock().unwrap().insert(spec.id, spec.clone());
172            Ok(())
173        }
174
175        async fn update_status(
176            &self,
177            spec_id: Uuid,
178            status: &str,
179            updated_at: DateTime<Utc>,
180        ) -> Result<(), AppError> {
181            let mut specs = self.specs.lock().unwrap();
182            if let Some(spec) = specs.get_mut(&spec_id) {
183                spec.status = TechnicalSpecStatus::from_str(status)?;
184                spec.updated_at = updated_at;
185            }
186            Ok(())
187        }
188
189        async fn find_by_id(&self, id: Uuid) -> Result<Option<TechnicalSpec>, AppError> {
190            Ok(self.specs.lock().unwrap().get(&id).cloned())
191        }
192
193        async fn list_for_acp(&self, acp_id: Uuid) -> Result<Vec<TechnicalSpec>, AppError> {
194            let specs = self.specs.lock().unwrap();
195            Ok(specs
196                .values()
197                .filter(|s| s.acp_id == acp_id)
198                .cloned()
199                .collect())
200        }
201
202        async fn save_signature(&self, sig: &TechnicalSpecSignature) -> Result<(), AppError> {
203            self.signatures.lock().unwrap().push(sig.clone());
204            Ok(())
205        }
206
207        async fn list_signatures_for_spec(
208            &self,
209            spec_id: Uuid,
210        ) -> Result<Vec<TechnicalSpecSignature>, AppError> {
211            let sigs = self.signatures.lock().unwrap();
212            Ok(sigs
213                .iter()
214                .filter(|s| s.technical_spec_id == spec_id)
215                .cloned()
216                .collect())
217        }
218    }
219
220    // ── Fixtures ───────────────────────────────────────────────────────────
221
222    fn make_use_cases() -> (
223        Arc<InMemoryEvalRepo>,
224        Arc<InMemoryTechSpecRepo>,
225        ContractorEvaluationUseCases,
226    ) {
227        let eval_repo: Arc<InMemoryEvalRepo> = Arc::new(InMemoryEvalRepo::default());
228        let spec_repo: Arc<InMemoryTechSpecRepo> = Arc::new(InMemoryTechSpecRepo::default());
229        let uc = ContractorEvaluationUseCases::new(
230            eval_repo.clone() as Arc<dyn ContractorEvaluationRepository>,
231            spec_repo.clone() as Arc<dyn TechnicalSpecRepository>,
232        );
233        (eval_repo, spec_repo, uc)
234    }
235
236    fn fixture_scores_all_top() -> EvaluationScores {
237        EvaluationScores {
238            quality: 5,
239            timeliness: 5,
240            communication: 5,
241            cost_compliance: 5,
242            overall: 5,
243        }
244    }
245
246    fn fixture_comment() -> String {
247        // 60+ chars to comfortably pass MIN_COMMENT_LEN (10).
248        "Travail soigné, livré dans les délais, communication impeccable.".to_string()
249    }
250
251    fn fixture_description() -> String {
252        // 50+ chars to satisfy TechnicalSpec MIN_DESCRIPTION_LEN.
253        "Renovation toiture batiment A : etancheite, isolation 18 cm laine de roche.".to_string()
254    }
255
256    fn fixture_deliverables() -> Vec<String> {
257        vec![
258            "Plan d'execution".to_string(),
259            "Cahier des charges".to_string(),
260        ]
261    }
262
263    async fn insert_spec_with_status(
264        spec_repo: &Arc<InMemoryTechSpecRepo>,
265        status: TechnicalSpecStatus,
266    ) -> Uuid {
267        let mut spec = TechnicalSpec::new(
268            Uuid::new_v4(),
269            None,
270            "Toiture".to_string(),
271            fixture_description(),
272            SemVer::new(1, 0, 0),
273            fixture_deliverables(),
274            vec![SignatoryRole::Syndic],
275            Vec::new(),
276            None,
277            Uuid::new_v4(),
278        )
279        .unwrap();
280        spec.status = status;
281        let id = spec.id;
282        spec_repo.save(&spec).await.unwrap();
283        id
284    }
285
286    // ---- @happy ---------------------------------------------------------
287
288    #[tokio::test]
289    async fn happy_create_evaluation_when_spec_is_approved() {
290        let (_eval_repo, spec_repo, uc) = make_use_cases();
291        let spec_id = insert_spec_with_status(&spec_repo, TechnicalSpecStatus::Approved).await;
292        let contractor = Uuid::new_v4();
293        let evaluator = Uuid::new_v4();
294        let e = uc
295            .create_evaluation(
296                contractor,
297                spec_id,
298                Vec::new(),
299                evaluator,
300                fixture_scores_all_top(),
301                fixture_comment(),
302            )
303            .await
304            .expect("evaluation must be created");
305        assert_eq!(e.contractor_user_id, contractor);
306        assert_eq!(e.technical_spec_id, spec_id);
307        assert_eq!(e.average_score(), 5.0);
308    }
309
310    #[tokio::test]
311    async fn happy_list_for_contractor_returns_newest_first() {
312        let (_eval_repo, spec_repo, uc) = make_use_cases();
313        let spec_id = insert_spec_with_status(&spec_repo, TechnicalSpecStatus::Approved).await;
314        let contractor = Uuid::new_v4();
315        let _first = uc
316            .create_evaluation(
317                contractor,
318                spec_id,
319                Vec::new(),
320                Uuid::new_v4(),
321                fixture_scores_all_top(),
322                fixture_comment(),
323            )
324            .await
325            .unwrap();
326        // Tiny sleep so created_at ordering is observable.
327        tokio::time::sleep(std::time::Duration::from_millis(2)).await;
328        let _second = uc
329            .create_evaluation(
330                contractor,
331                spec_id,
332                Vec::new(),
333                Uuid::new_v4(),
334                fixture_scores_all_top(),
335                fixture_comment(),
336            )
337            .await
338            .unwrap();
339        let listed = uc.list_for_contractor(contractor).await.unwrap();
340        assert_eq!(listed.len(), 2);
341        assert!(listed[0].created_at >= listed[1].created_at);
342    }
343
344    // ---- @edge ----------------------------------------------------------
345
346    #[tokio::test]
347    async fn edge_empty_linked_tickets_is_accepted() {
348        let (_eval_repo, spec_repo, uc) = make_use_cases();
349        let spec_id = insert_spec_with_status(&spec_repo, TechnicalSpecStatus::Approved).await;
350        let res = uc
351            .create_evaluation(
352                Uuid::new_v4(),
353                spec_id,
354                Vec::new(),
355                Uuid::new_v4(),
356                fixture_scores_all_top(),
357                fixture_comment(),
358            )
359            .await;
360        assert!(res.is_ok());
361    }
362
363    #[tokio::test]
364    async fn edge_spec_freshly_promoted_to_approved_is_accepted() {
365        // The use-case checks `status == Approved` strictly; a spec that was
366        // marked approved the same second as the evaluation is still valid.
367        let (_eval_repo, spec_repo, uc) = make_use_cases();
368        let spec_id = insert_spec_with_status(&spec_repo, TechnicalSpecStatus::Approved).await;
369        // Re-promote via update_status (idempotent on Approved).
370        spec_repo
371            .update_status(spec_id, "approved", Utc::now())
372            .await
373            .unwrap();
374        let res = uc
375            .create_evaluation(
376                Uuid::new_v4(),
377                spec_id,
378                Vec::new(),
379                Uuid::new_v4(),
380                fixture_scores_all_top(),
381                fixture_comment(),
382            )
383            .await;
384        assert!(res.is_ok());
385    }
386
387    // ---- @security ------------------------------------------------------
388
389    #[tokio::test]
390    async fn security_evaluator_equals_contractor_returns_typed_error() {
391        let (_eval_repo, spec_repo, uc) = make_use_cases();
392        let spec_id = insert_spec_with_status(&spec_repo, TechnicalSpecStatus::Approved).await;
393        let same = Uuid::new_v4();
394        let err = uc
395            .create_evaluation(
396                same,
397                spec_id,
398                Vec::new(),
399                same,
400                fixture_scores_all_top(),
401                fixture_comment(),
402            )
403            .await
404            .unwrap_err();
405        assert!(matches!(err, AppError::EvaluatorIsContractor));
406    }
407
408    #[tokio::test]
409    async fn security_double_save_with_unique_payload_is_idempotent_at_uc_level() {
410        // Two distinct create_evaluation calls mint two distinct ids; both
411        // succeed and persist as separate rows. The append-only DB constraint
412        // is checked in e2e/integration (cannot be unit-tested without the
413        // trigger). This test guards against an accidental dedup logic in
414        // the use-case.
415        let (eval_repo, spec_repo, uc) = make_use_cases();
416        let spec_id = insert_spec_with_status(&spec_repo, TechnicalSpecStatus::Approved).await;
417        let contractor = Uuid::new_v4();
418        let evaluator = Uuid::new_v4();
419        let _e1 = uc
420            .create_evaluation(
421                contractor,
422                spec_id,
423                Vec::new(),
424                evaluator,
425                fixture_scores_all_top(),
426                fixture_comment(),
427            )
428            .await
429            .unwrap();
430        let _e2 = uc
431            .create_evaluation(
432                contractor,
433                spec_id,
434                Vec::new(),
435                evaluator,
436                fixture_scores_all_top(),
437                fixture_comment(),
438            )
439            .await
440            .unwrap();
441        assert_eq!(eval_repo.rows.lock().unwrap().len(), 2);
442    }
443
444    // ---- @negative ------------------------------------------------------
445
446    #[tokio::test]
447    async fn negative_unknown_spec_returns_not_found() {
448        let (_eval_repo, _spec_repo, uc) = make_use_cases();
449        let err = uc
450            .create_evaluation(
451                Uuid::new_v4(),
452                Uuid::new_v4(), // never inserted
453                Vec::new(),
454                Uuid::new_v4(),
455                fixture_scores_all_top(),
456                fixture_comment(),
457            )
458            .await
459            .unwrap_err();
460        assert!(matches!(err, AppError::NotFound(_)));
461    }
462
463    #[tokio::test]
464    async fn negative_draft_spec_returns_technical_spec_required() {
465        let (_eval_repo, spec_repo, uc) = make_use_cases();
466        let spec_id = insert_spec_with_status(&spec_repo, TechnicalSpecStatus::Draft).await;
467        let err = uc
468            .create_evaluation(
469                Uuid::new_v4(),
470                spec_id,
471                Vec::new(),
472                Uuid::new_v4(),
473                fixture_scores_all_top(),
474                fixture_comment(),
475            )
476            .await
477            .unwrap_err();
478        assert!(matches!(err, AppError::TechnicalSpecRequired));
479    }
480
481    #[tokio::test]
482    async fn negative_pending_signatures_spec_returns_technical_spec_required() {
483        let (_eval_repo, spec_repo, uc) = make_use_cases();
484        let spec_id =
485            insert_spec_with_status(&spec_repo, TechnicalSpecStatus::PendingSignatures).await;
486        let err = uc
487            .create_evaluation(
488                Uuid::new_v4(),
489                spec_id,
490                Vec::new(),
491                Uuid::new_v4(),
492                fixture_scores_all_top(),
493                fixture_comment(),
494            )
495            .await
496            .unwrap_err();
497        assert!(matches!(err, AppError::TechnicalSpecRequired));
498    }
499
500    #[tokio::test]
501    async fn negative_superseded_spec_returns_technical_spec_required() {
502        let (_eval_repo, spec_repo, uc) = make_use_cases();
503        let spec_id = insert_spec_with_status(&spec_repo, TechnicalSpecStatus::Superseded).await;
504        let err = uc
505            .create_evaluation(
506                Uuid::new_v4(),
507                spec_id,
508                Vec::new(),
509                Uuid::new_v4(),
510                fixture_scores_all_top(),
511                fixture_comment(),
512            )
513            .await
514            .unwrap_err();
515        assert!(matches!(err, AppError::TechnicalSpecRequired));
516    }
517
518    #[tokio::test]
519    async fn negative_get_unknown_evaluation_returns_not_found() {
520        let (_eval_repo, _spec_repo, uc) = make_use_cases();
521        let err = uc.get_evaluation(Uuid::new_v4()).await.unwrap_err();
522        assert!(matches!(err, AppError::NotFound(_)));
523    }
524
525    #[tokio::test]
526    async fn negative_invalid_score_propagates_validation_error() {
527        let (_eval_repo, spec_repo, uc) = make_use_cases();
528        let spec_id = insert_spec_with_status(&spec_repo, TechnicalSpecStatus::Approved).await;
529        let bad = EvaluationScores {
530            quality: 5,
531            timeliness: 5,
532            communication: 5,
533            cost_compliance: 5,
534            overall: 0,
535        };
536        let err = uc
537            .create_evaluation(
538                Uuid::new_v4(),
539                spec_id,
540                Vec::new(),
541                Uuid::new_v4(),
542                bad,
543                fixture_comment(),
544            )
545            .await
546            .unwrap_err();
547        assert!(matches!(err, AppError::Validation(_)));
548    }
549}