1use 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
29pub 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 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 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 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#[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 #[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 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 #[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 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 "Travail soigné, livré dans les délais, communication impeccable.".to_string()
249 }
250
251 fn fixture_description() -> String {
252 "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 #[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 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 #[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 let (_eval_repo, spec_repo, uc) = make_use_cases();
368 let spec_id = insert_spec_with_status(&spec_repo, TechnicalSpecStatus::Approved).await;
369 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 #[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 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 #[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(), 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}