1use crate::application::error::AppError;
31use chrono::{DateTime, Utc};
32use serde::{Deserialize, Serialize};
33use uuid::Uuid;
34
35pub const MIN_TITLE_LEN: usize = 5;
40pub const MAX_TITLE_LEN: usize = 200;
41pub const MIN_DESCRIPTION_LEN: usize = 50;
42pub const MAX_DESCRIPTION_LEN: usize = 10_000;
43pub const MAX_DELIVERABLES: usize = 50;
44pub const MAX_REQUIRED_SIGNATURES: usize = 10;
45pub const MAX_ATTACHMENTS: usize = 20;
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58pub struct SemVer {
59 pub major: u32,
60 pub minor: u32,
61 pub patch: u32,
62}
63
64impl SemVer {
65 pub fn new(major: u32, minor: u32, patch: u32) -> Self {
66 Self {
67 major,
68 minor,
69 patch,
70 }
71 }
72}
73
74impl std::fmt::Display for SemVer {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
77 }
78}
79
80impl std::str::FromStr for SemVer {
81 type Err = AppError;
82
83 fn from_str(s: &str) -> Result<Self, Self::Err> {
84 let trimmed = s.trim();
86 if trimmed.is_empty() {
87 return Err(AppError::Validation("SemVer empty".to_string()));
88 }
89 if trimmed.starts_with('v') || trimmed.starts_with('V') {
90 return Err(AppError::Validation(format!(
91 "SemVer must not have a 'v' prefix: {}",
92 trimmed
93 )));
94 }
95 if trimmed.contains('-') || trimmed.contains('+') {
96 return Err(AppError::Validation(format!(
97 "SemVer must not carry pre-release or build metadata: {}",
98 trimmed
99 )));
100 }
101 let parts: Vec<&str> = trimmed.split('.').collect();
102 if parts.len() != 3 {
103 return Err(AppError::Validation(format!(
104 "SemVer must be major.minor.patch (got {})",
105 trimmed
106 )));
107 }
108 let major = parts[0]
109 .parse::<u32>()
110 .map_err(|_| AppError::Validation(format!("SemVer major not u32: {}", parts[0])))?;
111 let minor = parts[1]
112 .parse::<u32>()
113 .map_err(|_| AppError::Validation(format!("SemVer minor not u32: {}", parts[1])))?;
114 let patch = parts[2]
115 .parse::<u32>()
116 .map_err(|_| AppError::Validation(format!("SemVer patch not u32: {}", parts[2])))?;
117 Ok(SemVer {
118 major,
119 minor,
120 patch,
121 })
122 }
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
130pub enum TechnicalSpecStatus {
131 Draft,
133 PendingSignatures,
135 Approved,
137 Superseded,
139}
140
141impl std::fmt::Display for TechnicalSpecStatus {
142 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143 match self {
144 TechnicalSpecStatus::Draft => write!(f, "draft"),
145 TechnicalSpecStatus::PendingSignatures => write!(f, "pending_signatures"),
146 TechnicalSpecStatus::Approved => write!(f, "approved"),
147 TechnicalSpecStatus::Superseded => write!(f, "superseded"),
148 }
149 }
150}
151
152impl std::str::FromStr for TechnicalSpecStatus {
153 type Err = AppError;
154
155 fn from_str(s: &str) -> Result<Self, Self::Err> {
156 match s.trim().to_lowercase().as_str() {
157 "draft" => Ok(TechnicalSpecStatus::Draft),
158 "pending_signatures" => Ok(TechnicalSpecStatus::PendingSignatures),
159 "approved" => Ok(TechnicalSpecStatus::Approved),
160 "superseded" => Ok(TechnicalSpecStatus::Superseded),
161 other => Err(AppError::Validation(format!(
162 "Invalid TechnicalSpecStatus: {}",
163 other
164 ))),
165 }
166 }
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
178pub enum SignatoryRole {
179 Syndic,
180 Amo,
181 Lawyer,
182 Architect,
183 AcpRepresentative,
184}
185
186impl SignatoryRole {
187 pub fn requires_mandate(&self) -> bool {
189 matches!(
190 self,
191 SignatoryRole::Amo | SignatoryRole::Lawyer | SignatoryRole::Architect
192 )
193 }
194}
195
196impl std::fmt::Display for SignatoryRole {
197 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198 match self {
199 SignatoryRole::Syndic => write!(f, "syndic"),
200 SignatoryRole::Amo => write!(f, "amo"),
201 SignatoryRole::Lawyer => write!(f, "lawyer"),
202 SignatoryRole::Architect => write!(f, "architect"),
203 SignatoryRole::AcpRepresentative => write!(f, "acp_representative"),
204 }
205 }
206}
207
208impl std::str::FromStr for SignatoryRole {
209 type Err = AppError;
210
211 fn from_str(s: &str) -> Result<Self, Self::Err> {
212 match s.trim().to_lowercase().as_str() {
213 "syndic" => Ok(SignatoryRole::Syndic),
214 "amo" => Ok(SignatoryRole::Amo),
215 "lawyer" => Ok(SignatoryRole::Lawyer),
216 "architect" => Ok(SignatoryRole::Architect),
217 "acp_representative" => Ok(SignatoryRole::AcpRepresentative),
218 other => Err(AppError::Validation(format!(
219 "Invalid SignatoryRole: {}",
220 other
221 ))),
222 }
223 }
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct TechnicalSpec {
232 pub id: Uuid,
233 pub acp_id: Uuid,
234 pub building_id: Option<Uuid>,
235 pub title: String,
236 pub description: String,
237 pub version: SemVer,
238 pub status: TechnicalSpecStatus,
239 pub deliverables: Vec<String>,
240 pub required_signatures: Vec<SignatoryRole>,
241 pub attachments: Vec<String>,
242 pub previous_version_id: Option<Uuid>,
243 pub created_by: Uuid,
244 pub created_at: DateTime<Utc>,
245 pub updated_at: DateTime<Utc>,
246}
247
248impl TechnicalSpec {
249 #[allow(clippy::too_many_arguments)]
255 pub fn new(
256 acp_id: Uuid,
257 building_id: Option<Uuid>,
258 title: String,
259 description: String,
260 version: SemVer,
261 deliverables: Vec<String>,
262 required_signatures: Vec<SignatoryRole>,
263 attachments: Vec<String>,
264 previous_version_id: Option<Uuid>,
265 created_by: Uuid,
266 ) -> Result<Self, AppError> {
267 let trimmed_title = title.trim().to_string();
268 let trimmed_description = description.trim().to_string();
269 Self::validate_invariants(
270 acp_id,
271 &trimmed_title,
272 &trimmed_description,
273 &deliverables,
274 &required_signatures,
275 &attachments,
276 created_by,
277 )?;
278
279 let now = Utc::now();
280 Ok(Self {
281 id: Uuid::new_v4(),
282 acp_id,
283 building_id,
284 title: trimmed_title,
285 description: trimmed_description,
286 version,
287 status: TechnicalSpecStatus::Draft,
288 deliverables: deliverables
289 .into_iter()
290 .map(|d| d.trim().to_string())
291 .collect(),
292 required_signatures,
293 attachments,
294 previous_version_id,
295 created_by,
296 created_at: now,
297 updated_at: now,
298 })
299 }
300
301 fn validate_invariants(
302 acp_id: Uuid,
303 title: &str,
304 description: &str,
305 deliverables: &[String],
306 required_signatures: &[SignatoryRole],
307 attachments: &[String],
308 created_by: Uuid,
309 ) -> Result<(), AppError> {
310 if acp_id.is_nil() || created_by.is_nil() {
311 return Err(AppError::Validation(
312 "TechnicalSpec references must not be nil UUIDs".to_string(),
313 ));
314 }
315 let t_len = title.chars().count();
316 if t_len < MIN_TITLE_LEN || t_len > MAX_TITLE_LEN {
317 return Err(AppError::Validation(format!(
318 "title length must be in [{}, {}] (got {})",
319 MIN_TITLE_LEN, MAX_TITLE_LEN, t_len
320 )));
321 }
322 let d_len = description.chars().count();
323 if d_len < MIN_DESCRIPTION_LEN || d_len > MAX_DESCRIPTION_LEN {
324 return Err(AppError::Validation(format!(
325 "description length must be in [{}, {}] (got {})",
326 MIN_DESCRIPTION_LEN, MAX_DESCRIPTION_LEN, d_len
327 )));
328 }
329 if deliverables.is_empty() {
330 return Err(AppError::Validation(
331 "deliverables must contain at least one entry".to_string(),
332 ));
333 }
334 if deliverables.len() > MAX_DELIVERABLES {
335 return Err(AppError::Validation(format!(
336 "deliverables must contain at most {} entries (got {})",
337 MAX_DELIVERABLES,
338 deliverables.len()
339 )));
340 }
341 if deliverables.iter().any(|d| d.trim().is_empty()) {
342 return Err(AppError::Validation(
343 "deliverables entries must not be empty".to_string(),
344 ));
345 }
346 if required_signatures.is_empty() {
347 return Err(AppError::Validation(
348 "required_signatures must contain at least one role".to_string(),
349 ));
350 }
351 if required_signatures.len() > MAX_REQUIRED_SIGNATURES {
352 return Err(AppError::Validation(format!(
353 "required_signatures must contain at most {} roles (got {})",
354 MAX_REQUIRED_SIGNATURES,
355 required_signatures.len()
356 )));
357 }
358 if attachments.len() > MAX_ATTACHMENTS {
359 return Err(AppError::Validation(format!(
360 "attachments must contain at most {} entries (got {})",
361 MAX_ATTACHMENTS,
362 attachments.len()
363 )));
364 }
365 Ok(())
366 }
367
368 #[allow(clippy::too_many_arguments)]
377 pub fn bump(
378 &self,
379 new_version: SemVer,
380 new_title: Option<String>,
381 new_description: Option<String>,
382 new_deliverables: Option<Vec<String>>,
383 new_required_signatures: Option<Vec<SignatoryRole>>,
384 new_attachments: Option<Vec<String>>,
385 ) -> Result<Self, AppError> {
386 if !Self::is_strictly_greater(&new_version, &self.version) {
387 return Err(AppError::Validation(format!(
388 "new version {} must be strictly greater than {}",
389 new_version, self.version
390 )));
391 }
392 TechnicalSpec::new(
393 self.acp_id,
394 self.building_id,
395 new_title.unwrap_or_else(|| self.title.clone()),
396 new_description.unwrap_or_else(|| self.description.clone()),
397 new_version,
398 new_deliverables.unwrap_or_else(|| self.deliverables.clone()),
399 new_required_signatures.unwrap_or_else(|| self.required_signatures.clone()),
400 new_attachments.unwrap_or_else(|| self.attachments.clone()),
401 Some(self.id),
402 self.created_by,
403 )
404 }
405
406 pub fn is_strictly_greater(new: &SemVer, old: &SemVer) -> bool {
409 (new.major, new.minor, new.patch) > (old.major, old.minor, old.patch)
410 }
411
412 pub fn requires_resignature(&self, new: &SemVer) -> bool {
416 new.major > self.version.major
417 }
418
419 pub fn submit_for_signatures(&mut self) -> Result<(), AppError> {
421 match self.status {
422 TechnicalSpecStatus::Draft | TechnicalSpecStatus::PendingSignatures => {
423 self.status = TechnicalSpecStatus::PendingSignatures;
424 self.updated_at = Utc::now();
425 Ok(())
426 }
427 TechnicalSpecStatus::Approved => Err(AppError::TechnicalSpecAlreadyApproved),
428 TechnicalSpecStatus::Superseded => Err(AppError::Validation(
429 "Cannot submit a superseded TechnicalSpec".to_string(),
430 )),
431 }
432 }
433
434 pub fn mark_approved(&mut self) {
438 self.status = TechnicalSpecStatus::Approved;
439 self.updated_at = Utc::now();
440 }
441
442 pub fn has_all_required_signatures(&self, collected_roles: &[SignatoryRole]) -> bool {
444 self.required_signatures
445 .iter()
446 .all(|r| collected_roles.contains(r))
447 }
448}
449
450#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
455pub struct TechnicalSpecSignature {
456 pub id: Uuid,
457 pub technical_spec_id: Uuid,
458 pub signatory_user_id: Uuid,
459 pub role: SignatoryRole,
460 pub mandate_id: Option<Uuid>,
461 pub signed_at: DateTime<Utc>,
462}
463
464impl TechnicalSpecSignature {
465 pub fn new(
466 technical_spec_id: Uuid,
467 signatory_user_id: Uuid,
468 role: SignatoryRole,
469 mandate_id: Option<Uuid>,
470 ) -> Result<Self, AppError> {
471 if technical_spec_id.is_nil() || signatory_user_id.is_nil() {
472 return Err(AppError::Validation(
473 "TechnicalSpecSignature references must not be nil UUIDs".to_string(),
474 ));
475 }
476 if role.requires_mandate() && mandate_id.is_none() {
479 return Err(AppError::SignatoryNotAuthorized);
480 }
481 Ok(Self {
482 id: Uuid::new_v4(),
483 technical_spec_id,
484 signatory_user_id,
485 role,
486 mandate_id,
487 signed_at: Utc::now(),
488 })
489 }
490}
491
492#[cfg(test)]
497mod tests {
498 use super::*;
499 use std::str::FromStr;
500
501 fn fixture_acp_user() -> (Uuid, Uuid) {
502 (Uuid::new_v4(), Uuid::new_v4())
503 }
504
505 fn fixture_description() -> String {
506 "Renovation toiture bat. A : etancheite, isolation 18 cm laine de roche.".to_string()
508 }
509
510 fn fixture_deliverables() -> Vec<String> {
511 vec![
512 "Plan d'execution".to_string(),
513 "Cahier des charges detaille".to_string(),
514 ]
515 }
516
517 fn fixture_required_sigs() -> Vec<SignatoryRole> {
518 vec![SignatoryRole::Syndic, SignatoryRole::Architect]
519 }
520
521 #[test]
524 fn happy_semver_from_str_simple_triple() {
525 let v = SemVer::from_str("1.2.3").expect("valid SemVer must parse");
526 assert_eq!(v, SemVer::new(1, 2, 3));
527 assert_eq!(v.to_string(), "1.2.3");
528 }
529
530 #[test]
531 fn happy_semver_roundtrip_via_display_and_from_str() {
532 let v = SemVer::new(0, 1, 0);
533 let parsed = SemVer::from_str(&v.to_string()).unwrap();
534 assert_eq!(parsed, v);
535 }
536
537 #[test]
538 fn happy_create_minimal_draft_spec() {
539 let (acp, user) = fixture_acp_user();
540 let spec = TechnicalSpec::new(
541 acp,
542 None,
543 "Toiture".to_string(),
544 fixture_description(),
545 SemVer::new(0, 1, 0),
546 fixture_deliverables(),
547 fixture_required_sigs(),
548 Vec::new(),
549 None,
550 user,
551 )
552 .expect("valid TechnicalSpec must be created");
553 assert_eq!(spec.status, TechnicalSpecStatus::Draft);
554 assert_eq!(spec.acp_id, acp);
555 assert_eq!(spec.created_by, user);
556 assert_eq!(spec.version, SemVer::new(0, 1, 0));
557 assert!(spec.previous_version_id.is_none());
558 }
559
560 #[test]
561 fn happy_major_bump_requires_resignature_minor_does_not() {
562 let (acp, user) = fixture_acp_user();
563 let spec = TechnicalSpec::new(
564 acp,
565 None,
566 "Toiture".to_string(),
567 fixture_description(),
568 SemVer::new(1, 5, 7),
569 fixture_deliverables(),
570 fixture_required_sigs(),
571 Vec::new(),
572 None,
573 user,
574 )
575 .unwrap();
576 assert!(spec.requires_resignature(&SemVer::new(2, 0, 0)));
577 assert!(!spec.requires_resignature(&SemVer::new(1, 6, 0)));
578 assert!(!spec.requires_resignature(&SemVer::new(1, 5, 8)));
579 }
580
581 #[test]
582 fn happy_has_all_required_signatures_truthy() {
583 let (acp, user) = fixture_acp_user();
584 let spec = TechnicalSpec::new(
585 acp,
586 None,
587 "Facade".to_string(),
588 fixture_description(),
589 SemVer::new(1, 0, 0),
590 fixture_deliverables(),
591 vec![SignatoryRole::Syndic, SignatoryRole::Architect],
592 Vec::new(),
593 None,
594 user,
595 )
596 .unwrap();
597 assert!(
598 spec.has_all_required_signatures(&[SignatoryRole::Syndic, SignatoryRole::Architect,])
599 );
600 assert!(!spec.has_all_required_signatures(&[SignatoryRole::Syndic]));
601 }
602
603 #[test]
604 fn happy_signatory_role_roundtrips_via_display_and_from_str() {
605 for r in [
606 SignatoryRole::Syndic,
607 SignatoryRole::Amo,
608 SignatoryRole::Lawyer,
609 SignatoryRole::Architect,
610 SignatoryRole::AcpRepresentative,
611 ] {
612 let s = r.to_string();
613 assert_eq!(SignatoryRole::from_str(&s).unwrap(), r);
614 }
615 }
616
617 #[test]
618 fn happy_submit_for_signatures_from_draft() {
619 let (acp, user) = fixture_acp_user();
620 let mut spec = TechnicalSpec::new(
621 acp,
622 None,
623 "Toiture".to_string(),
624 fixture_description(),
625 SemVer::new(0, 1, 0),
626 fixture_deliverables(),
627 fixture_required_sigs(),
628 Vec::new(),
629 None,
630 user,
631 )
632 .unwrap();
633 spec.submit_for_signatures().expect("draft submits");
634 assert_eq!(spec.status, TechnicalSpecStatus::PendingSignatures);
635 }
636
637 #[test]
638 fn happy_signatory_role_requires_mandate_for_external_pros_only() {
639 assert!(SignatoryRole::Amo.requires_mandate());
640 assert!(SignatoryRole::Lawyer.requires_mandate());
641 assert!(SignatoryRole::Architect.requires_mandate());
642 assert!(!SignatoryRole::Syndic.requires_mandate());
643 assert!(!SignatoryRole::AcpRepresentative.requires_mandate());
644 }
645
646 #[test]
649 fn edge_title_at_min_len_is_accepted() {
650 let (acp, user) = fixture_acp_user();
651 let title = "X".repeat(MIN_TITLE_LEN);
652 let res = TechnicalSpec::new(
653 acp,
654 None,
655 title,
656 fixture_description(),
657 SemVer::new(1, 0, 0),
658 fixture_deliverables(),
659 fixture_required_sigs(),
660 Vec::new(),
661 None,
662 user,
663 );
664 assert!(res.is_ok(), "title exactly MIN must succeed");
665 }
666
667 #[test]
668 fn edge_title_one_under_min_is_rejected() {
669 let (acp, user) = fixture_acp_user();
670 let title = "X".repeat(MIN_TITLE_LEN - 1);
671 let err = TechnicalSpec::new(
672 acp,
673 None,
674 title,
675 fixture_description(),
676 SemVer::new(1, 0, 0),
677 fixture_deliverables(),
678 fixture_required_sigs(),
679 Vec::new(),
680 None,
681 user,
682 )
683 .unwrap_err();
684 assert!(matches!(err, AppError::Validation(_)));
685 }
686
687 #[test]
688 fn edge_exactly_50_deliverables_is_accepted_51_rejected() {
689 let (acp, user) = fixture_acp_user();
690 let exactly = (0..MAX_DELIVERABLES)
691 .map(|i| format!("livrable-{}", i))
692 .collect::<Vec<_>>();
693 let ok = TechnicalSpec::new(
694 acp,
695 None,
696 "Toiture".to_string(),
697 fixture_description(),
698 SemVer::new(1, 0, 0),
699 exactly,
700 fixture_required_sigs(),
701 Vec::new(),
702 None,
703 user,
704 );
705 assert!(ok.is_ok(), "50 deliverables must succeed");
706
707 let too_many = (0..=MAX_DELIVERABLES)
708 .map(|i| format!("livrable-{}", i))
709 .collect::<Vec<_>>();
710 let err = TechnicalSpec::new(
711 acp,
712 None,
713 "Toiture".to_string(),
714 fixture_description(),
715 SemVer::new(1, 0, 0),
716 too_many,
717 fixture_required_sigs(),
718 Vec::new(),
719 None,
720 user,
721 )
722 .unwrap_err();
723 assert!(matches!(err, AppError::Validation(_)));
724 }
725
726 #[test]
727 fn edge_requires_resignature_only_on_major_bump() {
728 let (acp, user) = fixture_acp_user();
729 let spec = TechnicalSpec::new(
730 acp,
731 None,
732 "Toiture".to_string(),
733 fixture_description(),
734 SemVer::new(1, 5, 7),
735 fixture_deliverables(),
736 fixture_required_sigs(),
737 Vec::new(),
738 None,
739 user,
740 )
741 .unwrap();
742 assert!(!spec.requires_resignature(&SemVer::new(1, 6, 0)));
744 assert!(!spec.requires_resignature(&SemVer::new(1, 5, 8)));
746 assert!(spec.requires_resignature(&SemVer::new(2, 0, 0)));
748 }
749
750 #[test]
751 fn edge_bump_increments_version_and_resets_to_draft() {
752 let (acp, user) = fixture_acp_user();
753 let v1 = TechnicalSpec::new(
754 acp,
755 None,
756 "Toiture".to_string(),
757 fixture_description(),
758 SemVer::new(1, 0, 0),
759 fixture_deliverables(),
760 fixture_required_sigs(),
761 Vec::new(),
762 None,
763 user,
764 )
765 .unwrap();
766 let mut v1_approved = v1.clone();
768 v1_approved.mark_approved();
769 let v2 = v1_approved
770 .bump(SemVer::new(1, 1, 0), None, None, None, None, None)
771 .expect("minor bump must succeed");
772 assert_eq!(v2.status, TechnicalSpecStatus::Draft);
773 assert_eq!(v2.previous_version_id, Some(v1_approved.id));
774 assert_eq!(v2.version, SemVer::new(1, 1, 0));
775 }
776
777 #[test]
780 fn security_semver_rejects_v_prefix() {
781 let err = SemVer::from_str("v1.2.3").unwrap_err();
782 assert!(matches!(err, AppError::Validation(_)));
783 }
784
785 #[test]
786 fn security_semver_rejects_pre_release_suffix() {
787 let err = SemVer::from_str("1.2.3-rc1").unwrap_err();
788 assert!(matches!(err, AppError::Validation(_)));
789 }
790
791 #[test]
792 fn security_semver_rejects_build_metadata() {
793 let err = SemVer::from_str("1.2.3+build5").unwrap_err();
794 assert!(matches!(err, AppError::Validation(_)));
795 }
796
797 #[test]
798 fn security_semver_rejects_two_components_only() {
799 let err = SemVer::from_str("1.2").unwrap_err();
800 assert!(matches!(err, AppError::Validation(_)));
801 }
802
803 #[test]
804 fn security_semver_rejects_negative_components() {
805 let err = SemVer::from_str("-1.0.0").unwrap_err();
807 assert!(matches!(err, AppError::Validation(_)));
808 }
809
810 #[test]
811 fn security_nil_uuids_rejected_in_constructor() {
812 let (acp, user) = fixture_acp_user();
813 let err_acp = TechnicalSpec::new(
814 Uuid::nil(),
815 None,
816 "Toiture".to_string(),
817 fixture_description(),
818 SemVer::new(1, 0, 0),
819 fixture_deliverables(),
820 fixture_required_sigs(),
821 Vec::new(),
822 None,
823 user,
824 )
825 .unwrap_err();
826 assert!(matches!(err_acp, AppError::Validation(_)));
827
828 let err_user = TechnicalSpec::new(
829 acp,
830 None,
831 "Toiture".to_string(),
832 fixture_description(),
833 SemVer::new(1, 0, 0),
834 fixture_deliverables(),
835 fixture_required_sigs(),
836 Vec::new(),
837 None,
838 Uuid::nil(),
839 )
840 .unwrap_err();
841 assert!(matches!(err_user, AppError::Validation(_)));
842 }
843
844 #[test]
845 fn security_signature_for_mandataire_role_without_mandate_rejected() {
846 let err = TechnicalSpecSignature::new(
847 Uuid::new_v4(),
848 Uuid::new_v4(),
849 SignatoryRole::Lawyer,
850 None,
851 )
852 .unwrap_err();
853 assert!(matches!(err, AppError::SignatoryNotAuthorized));
854 }
855
856 #[test]
859 fn negative_empty_title_rejected() {
860 let (acp, user) = fixture_acp_user();
861 let err = TechnicalSpec::new(
862 acp,
863 None,
864 String::new(),
865 fixture_description(),
866 SemVer::new(1, 0, 0),
867 fixture_deliverables(),
868 fixture_required_sigs(),
869 Vec::new(),
870 None,
871 user,
872 )
873 .unwrap_err();
874 assert!(matches!(err, AppError::Validation(_)));
875 }
876
877 #[test]
878 fn negative_description_too_short_rejected() {
879 let (acp, user) = fixture_acp_user();
880 let err = TechnicalSpec::new(
881 acp,
882 None,
883 "Toiture".to_string(),
884 "trop court".to_string(),
885 SemVer::new(1, 0, 0),
886 fixture_deliverables(),
887 fixture_required_sigs(),
888 Vec::new(),
889 None,
890 user,
891 )
892 .unwrap_err();
893 assert!(matches!(err, AppError::Validation(_)));
894 }
895
896 #[test]
897 fn negative_empty_deliverables_rejected() {
898 let (acp, user) = fixture_acp_user();
899 let err = TechnicalSpec::new(
900 acp,
901 None,
902 "Toiture".to_string(),
903 fixture_description(),
904 SemVer::new(1, 0, 0),
905 Vec::new(),
906 fixture_required_sigs(),
907 Vec::new(),
908 None,
909 user,
910 )
911 .unwrap_err();
912 assert!(matches!(err, AppError::Validation(_)));
913 }
914
915 #[test]
916 fn negative_blank_deliverable_entry_rejected() {
917 let (acp, user) = fixture_acp_user();
918 let err = TechnicalSpec::new(
919 acp,
920 None,
921 "Toiture".to_string(),
922 fixture_description(),
923 SemVer::new(1, 0, 0),
924 vec!["valid".to_string(), " ".to_string()],
925 fixture_required_sigs(),
926 Vec::new(),
927 None,
928 user,
929 )
930 .unwrap_err();
931 assert!(matches!(err, AppError::Validation(_)));
932 }
933
934 #[test]
935 fn negative_empty_required_signatures_rejected() {
936 let (acp, user) = fixture_acp_user();
937 let err = TechnicalSpec::new(
938 acp,
939 None,
940 "Toiture".to_string(),
941 fixture_description(),
942 SemVer::new(1, 0, 0),
943 fixture_deliverables(),
944 Vec::new(),
945 Vec::new(),
946 None,
947 user,
948 )
949 .unwrap_err();
950 assert!(matches!(err, AppError::Validation(_)));
951 }
952
953 #[test]
954 fn negative_too_many_attachments_rejected() {
955 let (acp, user) = fixture_acp_user();
956 let too_many: Vec<String> = (0..=MAX_ATTACHMENTS)
957 .map(|i| format!("s3://x/{}", i))
958 .collect();
959 let err = TechnicalSpec::new(
960 acp,
961 None,
962 "Toiture".to_string(),
963 fixture_description(),
964 SemVer::new(1, 0, 0),
965 fixture_deliverables(),
966 fixture_required_sigs(),
967 too_many,
968 None,
969 user,
970 )
971 .unwrap_err();
972 assert!(matches!(err, AppError::Validation(_)));
973 }
974
975 #[test]
976 fn negative_bump_to_equal_or_lower_version_rejected() {
977 let (acp, user) = fixture_acp_user();
978 let spec = TechnicalSpec::new(
979 acp,
980 None,
981 "Toiture".to_string(),
982 fixture_description(),
983 SemVer::new(1, 2, 3),
984 fixture_deliverables(),
985 fixture_required_sigs(),
986 Vec::new(),
987 None,
988 user,
989 )
990 .unwrap();
991 let err_equal = spec
992 .bump(SemVer::new(1, 2, 3), None, None, None, None, None)
993 .unwrap_err();
994 assert!(matches!(err_equal, AppError::Validation(_)));
995 let err_lower = spec
996 .bump(SemVer::new(1, 2, 2), None, None, None, None, None)
997 .unwrap_err();
998 assert!(matches!(err_lower, AppError::Validation(_)));
999 }
1000
1001 #[test]
1002 fn negative_submit_approved_spec_rejected() {
1003 let (acp, user) = fixture_acp_user();
1004 let mut spec = TechnicalSpec::new(
1005 acp,
1006 None,
1007 "Toiture".to_string(),
1008 fixture_description(),
1009 SemVer::new(1, 0, 0),
1010 fixture_deliverables(),
1011 fixture_required_sigs(),
1012 Vec::new(),
1013 None,
1014 user,
1015 )
1016 .unwrap();
1017 spec.mark_approved();
1018 let err = spec.submit_for_signatures().unwrap_err();
1019 assert!(matches!(err, AppError::TechnicalSpecAlreadyApproved));
1020 }
1021
1022 #[test]
1023 fn negative_invalid_status_string_rejected() {
1024 let err = TechnicalSpecStatus::from_str("voted").unwrap_err();
1025 assert!(matches!(err, AppError::Validation(_)));
1026 }
1027
1028 #[test]
1029 fn negative_invalid_signatory_role_string_rejected() {
1030 let err = SignatoryRole::from_str("plombier").unwrap_err();
1031 assert!(matches!(err, AppError::Validation(_)));
1032 }
1033}