1use crate::application::error::AppError;
38use chrono::{DateTime, Utc};
39use serde::{Deserialize, Serialize};
40use std::collections::HashSet;
41use uuid::Uuid;
42
43pub const MIN_SCORE: u8 = 1;
48pub const MAX_SCORE: u8 = 5;
49pub const MIN_COMMENT_LEN: usize = 10;
50pub const MAX_COMMENT_LEN: usize = 2000;
51pub const MAX_LINKED_TICKETS: usize = 20;
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62pub struct EvaluationScores {
63 pub quality: u8,
65 pub timeliness: u8,
67 pub communication: u8,
69 pub cost_compliance: u8,
71 pub overall: u8,
73}
74
75impl EvaluationScores {
76 fn is_within_bounds(&self) -> bool {
78 let in_bounds = |s: u8| s >= MIN_SCORE && s <= MAX_SCORE;
79 in_bounds(self.quality)
80 && in_bounds(self.timeliness)
81 && in_bounds(self.communication)
82 && in_bounds(self.cost_compliance)
83 && in_bounds(self.overall)
84 }
85
86 pub fn average(&self) -> f64 {
89 let sum = self.quality as u32
90 + self.timeliness as u32
91 + self.communication as u32
92 + self.cost_compliance as u32
93 + self.overall as u32;
94 sum as f64 / 5.0
95 }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct ContractorEvaluation {
104 pub id: Uuid,
105 pub contractor_user_id: Uuid,
107 pub technical_spec_id: Uuid,
110 pub linked_ticket_ids: Vec<Uuid>,
114 pub evaluator_user_id: Uuid,
116 pub scores: EvaluationScores,
117 pub comment: String,
121 pub created_at: DateTime<Utc>,
122}
123
124impl ContractorEvaluation {
125 pub fn new(
129 contractor_user_id: Uuid,
130 technical_spec_id: Uuid,
131 linked_ticket_ids: Vec<Uuid>,
132 evaluator_user_id: Uuid,
133 scores: EvaluationScores,
134 comment: String,
135 ) -> Result<Self, AppError> {
136 let trimmed_comment = comment.trim().to_string();
137 Self::validate_invariants(
138 contractor_user_id,
139 technical_spec_id,
140 &linked_ticket_ids,
141 evaluator_user_id,
142 &scores,
143 &trimmed_comment,
144 )?;
145
146 Ok(Self {
147 id: Uuid::new_v4(),
148 contractor_user_id,
149 technical_spec_id,
150 linked_ticket_ids,
151 evaluator_user_id,
152 scores,
153 comment: trimmed_comment,
154 created_at: Utc::now(),
155 })
156 }
157
158 fn validate_invariants(
159 contractor_user_id: Uuid,
160 technical_spec_id: Uuid,
161 linked_ticket_ids: &[Uuid],
162 evaluator_user_id: Uuid,
163 scores: &EvaluationScores,
164 comment: &str,
165 ) -> Result<(), AppError> {
166 if contractor_user_id.is_nil() || technical_spec_id.is_nil() || evaluator_user_id.is_nil() {
168 return Err(AppError::Validation(
169 "ContractorEvaluation references must not be nil UUIDs".to_string(),
170 ));
171 }
172 if evaluator_user_id == contractor_user_id {
175 return Err(AppError::EvaluatorIsContractor);
176 }
177 if !scores.is_within_bounds() {
179 return Err(AppError::Validation(format!(
180 "ContractorEvaluation scores must be in [{}, {}] for every dimension",
181 MIN_SCORE, MAX_SCORE
182 )));
183 }
184 if linked_ticket_ids.len() > MAX_LINKED_TICKETS {
186 return Err(AppError::Validation(format!(
187 "linked_ticket_ids must contain at most {} entries (got {})",
188 MAX_LINKED_TICKETS,
189 linked_ticket_ids.len()
190 )));
191 }
192 let mut seen: HashSet<Uuid> = HashSet::with_capacity(linked_ticket_ids.len());
193 for id in linked_ticket_ids {
194 if id.is_nil() {
195 return Err(AppError::Validation(
196 "linked_ticket_ids must not contain nil UUIDs".to_string(),
197 ));
198 }
199 if !seen.insert(*id) {
200 return Err(AppError::Validation(
201 "linked_ticket_ids must not contain duplicates".to_string(),
202 ));
203 }
204 }
205 let c_len = comment.chars().count();
208 if c_len < MIN_COMMENT_LEN || c_len > MAX_COMMENT_LEN {
209 return Err(AppError::Validation(format!(
210 "comment length must be in [{}, {}] (got {})",
211 MIN_COMMENT_LEN, MAX_COMMENT_LEN, c_len
212 )));
213 }
214 Ok(())
215 }
216
217 pub fn average_score(&self) -> f64 {
220 self.scores.average()
221 }
222}
223
224#[cfg(test)]
229mod tests {
230 use super::*;
231
232 fn fixture_pair() -> (Uuid, Uuid) {
233 (Uuid::new_v4(), Uuid::new_v4())
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_scores_all_bottom() -> EvaluationScores {
247 EvaluationScores {
248 quality: 1,
249 timeliness: 1,
250 communication: 1,
251 cost_compliance: 1,
252 overall: 1,
253 }
254 }
255
256 fn fixture_comment_min() -> String {
257 "X".repeat(MIN_COMMENT_LEN)
259 }
260
261 fn build_ok(scores: EvaluationScores, comment: String) -> ContractorEvaluation {
262 let (contractor, evaluator) = fixture_pair();
263 ContractorEvaluation::new(
264 contractor,
265 Uuid::new_v4(),
266 Vec::new(),
267 evaluator,
268 scores,
269 comment,
270 )
271 .expect("valid ContractorEvaluation must be created")
272 }
273
274 #[test]
277 fn happy_minimal_evaluation_all_top_scores() {
278 let e = build_ok(fixture_scores_all_top(), fixture_comment_min());
279 assert_eq!(e.scores.quality, 5);
280 assert_eq!(e.average_score(), 5.0);
281 assert!(e.linked_ticket_ids.is_empty());
282 }
283
284 #[test]
285 fn happy_all_bottom_scores_accepted() {
286 let e = build_ok(fixture_scores_all_bottom(), fixture_comment_min());
287 assert_eq!(e.average_score(), 1.0);
288 }
289
290 #[test]
291 fn happy_average_score_mid_value() {
292 let scores = EvaluationScores {
294 quality: 3,
295 timeliness: 3,
296 communication: 3,
297 cost_compliance: 3,
298 overall: 3,
299 };
300 let e = build_ok(scores, fixture_comment_min());
301 assert_eq!(e.average_score(), 3.0);
302 }
303
304 #[test]
305 fn happy_evaluator_differs_from_contractor_persisted() {
306 let e = build_ok(fixture_scores_all_top(), fixture_comment_min());
307 assert_ne!(e.evaluator_user_id, e.contractor_user_id);
308 }
309
310 #[test]
311 fn happy_comment_is_trimmed_before_storage() {
312 let (contractor, evaluator) = fixture_pair();
313 let e = ContractorEvaluation::new(
314 contractor,
315 Uuid::new_v4(),
316 Vec::new(),
317 evaluator,
318 fixture_scores_all_top(),
319 format!(" {} ", fixture_comment_min()),
320 )
321 .unwrap();
322 assert!(!e.comment.starts_with(' '));
323 assert!(!e.comment.ends_with(' '));
324 }
325
326 #[test]
329 fn edge_exactly_max_linked_tickets_accepted() {
330 let (contractor, evaluator) = fixture_pair();
331 let tickets: Vec<Uuid> = (0..MAX_LINKED_TICKETS).map(|_| Uuid::new_v4()).collect();
332 let res = ContractorEvaluation::new(
333 contractor,
334 Uuid::new_v4(),
335 tickets,
336 evaluator,
337 fixture_scores_all_top(),
338 fixture_comment_min(),
339 );
340 assert!(res.is_ok(), "exactly MAX_LINKED_TICKETS must succeed");
341 }
342
343 #[test]
344 fn edge_one_over_max_linked_tickets_rejected() {
345 let (contractor, evaluator) = fixture_pair();
346 let tickets: Vec<Uuid> = (0..=MAX_LINKED_TICKETS).map(|_| Uuid::new_v4()).collect();
347 let err = ContractorEvaluation::new(
348 contractor,
349 Uuid::new_v4(),
350 tickets,
351 evaluator,
352 fixture_scores_all_top(),
353 fixture_comment_min(),
354 )
355 .unwrap_err();
356 assert!(matches!(err, AppError::Validation(_)));
357 }
358
359 #[test]
360 fn edge_comment_exactly_min_len_accepted() {
361 let (contractor, evaluator) = fixture_pair();
362 let res = ContractorEvaluation::new(
363 contractor,
364 Uuid::new_v4(),
365 Vec::new(),
366 evaluator,
367 fixture_scores_all_top(),
368 "X".repeat(MIN_COMMENT_LEN),
369 );
370 assert!(res.is_ok());
371 }
372
373 #[test]
374 fn edge_comment_one_under_min_rejected() {
375 let (contractor, evaluator) = fixture_pair();
376 let err = ContractorEvaluation::new(
377 contractor,
378 Uuid::new_v4(),
379 Vec::new(),
380 evaluator,
381 fixture_scores_all_top(),
382 "X".repeat(MIN_COMMENT_LEN - 1),
383 )
384 .unwrap_err();
385 assert!(matches!(err, AppError::Validation(_)));
386 }
387
388 #[test]
389 fn edge_comment_exactly_max_len_accepted() {
390 let (contractor, evaluator) = fixture_pair();
391 let res = ContractorEvaluation::new(
392 contractor,
393 Uuid::new_v4(),
394 Vec::new(),
395 evaluator,
396 fixture_scores_all_top(),
397 "X".repeat(MAX_COMMENT_LEN),
398 );
399 assert!(res.is_ok());
400 }
401
402 #[test]
405 fn security_evaluator_equals_contractor_returns_typed_error() {
406 let same = Uuid::new_v4();
407 let err = ContractorEvaluation::new(
408 same,
409 Uuid::new_v4(),
410 Vec::new(),
411 same,
412 fixture_scores_all_top(),
413 fixture_comment_min(),
414 )
415 .unwrap_err();
416 assert!(matches!(err, AppError::EvaluatorIsContractor));
417 }
418
419 #[test]
420 fn security_duplicate_linked_tickets_rejected() {
421 let (contractor, evaluator) = fixture_pair();
422 let t = Uuid::new_v4();
423 let err = ContractorEvaluation::new(
424 contractor,
425 Uuid::new_v4(),
426 vec![t, t],
427 evaluator,
428 fixture_scores_all_top(),
429 fixture_comment_min(),
430 )
431 .unwrap_err();
432 assert!(matches!(err, AppError::Validation(_)));
433 }
434
435 #[test]
436 fn security_nil_uuids_rejected() {
437 let evaluator = Uuid::new_v4();
438 let err1 = ContractorEvaluation::new(
440 Uuid::nil(),
441 Uuid::new_v4(),
442 Vec::new(),
443 evaluator,
444 fixture_scores_all_top(),
445 fixture_comment_min(),
446 )
447 .unwrap_err();
448 assert!(matches!(err1, AppError::Validation(_)));
449 let err2 = ContractorEvaluation::new(
451 Uuid::new_v4(),
452 Uuid::nil(),
453 Vec::new(),
454 evaluator,
455 fixture_scores_all_top(),
456 fixture_comment_min(),
457 )
458 .unwrap_err();
459 assert!(matches!(err2, AppError::Validation(_)));
460 let err3 = ContractorEvaluation::new(
462 Uuid::new_v4(),
463 Uuid::new_v4(),
464 Vec::new(),
465 Uuid::nil(),
466 fixture_scores_all_top(),
467 fixture_comment_min(),
468 )
469 .unwrap_err();
470 assert!(matches!(err3, AppError::Validation(_)));
471 }
472
473 #[test]
474 fn security_nil_linked_ticket_id_rejected() {
475 let (contractor, evaluator) = fixture_pair();
476 let err = ContractorEvaluation::new(
477 contractor,
478 Uuid::new_v4(),
479 vec![Uuid::nil()],
480 evaluator,
481 fixture_scores_all_top(),
482 fixture_comment_min(),
483 )
484 .unwrap_err();
485 assert!(matches!(err, AppError::Validation(_)));
486 }
487
488 #[test]
491 fn negative_score_zero_rejected() {
492 let (contractor, evaluator) = fixture_pair();
493 let scores = EvaluationScores {
494 quality: 0,
495 timeliness: 5,
496 communication: 5,
497 cost_compliance: 5,
498 overall: 5,
499 };
500 let err = ContractorEvaluation::new(
501 contractor,
502 Uuid::new_v4(),
503 Vec::new(),
504 evaluator,
505 scores,
506 fixture_comment_min(),
507 )
508 .unwrap_err();
509 assert!(matches!(err, AppError::Validation(_)));
510 }
511
512 #[test]
513 fn negative_score_six_rejected() {
514 let (contractor, evaluator) = fixture_pair();
515 let scores = EvaluationScores {
516 quality: 5,
517 timeliness: 5,
518 communication: 5,
519 cost_compliance: 5,
520 overall: 6,
521 };
522 let err = ContractorEvaluation::new(
523 contractor,
524 Uuid::new_v4(),
525 Vec::new(),
526 evaluator,
527 scores,
528 fixture_comment_min(),
529 )
530 .unwrap_err();
531 assert!(matches!(err, AppError::Validation(_)));
532 }
533
534 #[test]
535 fn negative_comment_too_long_rejected() {
536 let (contractor, evaluator) = fixture_pair();
537 let err = ContractorEvaluation::new(
538 contractor,
539 Uuid::new_v4(),
540 Vec::new(),
541 evaluator,
542 fixture_scores_all_top(),
543 "X".repeat(MAX_COMMENT_LEN + 1),
544 )
545 .unwrap_err();
546 assert!(matches!(err, AppError::Validation(_)));
547 }
548
549 #[test]
550 fn negative_empty_comment_rejected() {
551 let (contractor, evaluator) = fixture_pair();
552 let err = ContractorEvaluation::new(
553 contractor,
554 Uuid::new_v4(),
555 Vec::new(),
556 evaluator,
557 fixture_scores_all_top(),
558 String::new(),
559 )
560 .unwrap_err();
561 assert!(matches!(err, AppError::Validation(_)));
562 }
563
564 #[test]
565 fn negative_whitespace_only_comment_rejected() {
566 let (contractor, evaluator) = fixture_pair();
567 let err = ContractorEvaluation::new(
568 contractor,
569 Uuid::new_v4(),
570 Vec::new(),
571 evaluator,
572 fixture_scores_all_top(),
573 " ".to_string(), )
575 .unwrap_err();
576 assert!(matches!(err, AppError::Validation(_)));
577 }
578}