Skip to main content

koprogo_api/domain/economie_circulaire/
technical_spec.rs

1//! TechnicalSpec — cahier des charges versionnable + signatures multi-parties
2//! (Story 3.8 — FR33).
3//!
4//! A [`TechnicalSpec`] materialises a syndic-produced specification for an
5//! ACP (or a specific building inside the ACP). It is versionnable with a
6//! strict SemVer-like triple ([`SemVer`]) and signed off by one or more
7//! parties ([`SignatoryRole`]).
8//!
9//! # Workflow
10//!
11//! 1. The syndic creates a spec in [`TechnicalSpecStatus::Draft`].
12//! 2. Once ready, the syndic submits it: the status moves to
13//!    [`TechnicalSpecStatus::PendingSignatures`] (no further edits).
14//! 3. Each `required_signatures` slot is filled via a
15//!    [`TechnicalSpecSignature`] append-only row. When all required slots
16//!    are filled, the use case promotes the spec to
17//!    [`TechnicalSpecStatus::Approved`].
18//! 4. A subsequent version bumps the spec ([`TechnicalSpec::bump`]) — if the
19//!    bump is *major* (`requires_resignature` is true), the new draft must
20//!    collect fresh signatures from every required signatory.
21//!
22//! # Invariants enforced at `new()` time
23//!
24//! - `title.len() in [5, 200]`
25//! - `description.len() in [50, 10_000]`
26//! - `deliverables` non empty, at most 50 entries, each non empty
27//! - `required_signatures` non empty, at most 10
28//! - `attachments` at most 20
29
30use crate::application::error::AppError;
31use chrono::{DateTime, Utc};
32use serde::{Deserialize, Serialize};
33use uuid::Uuid;
34
35// ============================================================================
36// Bound constants
37// ============================================================================
38
39pub 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// ============================================================================
48// SemVer — strict major.minor.patch (no v-prefix, no pre-release)
49// ============================================================================
50
51/// Strict semantic version triple (`major.minor.patch`).
52///
53/// Parsing is intentionally restrictive: no leading `v`, no pre-release
54/// suffix, no build metadata. The codebase only needs the three numeric
55/// components to decide whether a [`TechnicalSpec`] bump requires fresh
56/// signatures (`major` increment) or not (`minor` / `patch`).
57#[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        // Strict: no v-prefix, no pre-release, no build metadata.
85        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// ============================================================================
126// Status — finite state machine for a TechnicalSpec
127// ============================================================================
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
130pub enum TechnicalSpecStatus {
131    /// Created but not yet submitted for signatures.
132    Draft,
133    /// Submitted — awaiting signatures from every required signatory.
134    PendingSignatures,
135    /// All required signatures collected.
136    Approved,
137    /// Replaced by a more recent version (bump chain).
138    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// ============================================================================
170// SignatoryRole
171// ============================================================================
172
173/// Role authorised to sign a [`TechnicalSpec`]. Mandataire roles (AMO,
174/// Lawyer, Architect) must additionally carry an active
175/// [`crate::domain::entities::Mandate`] covering the spec's ACP — the use
176/// case enforces this guard.
177#[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    /// Whether signing under this role requires an active Mandate (Story 3.4).
188    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// ============================================================================
227// TechnicalSpec entity
228// ============================================================================
229
230#[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    /// Build a new TechnicalSpec in `Draft` status.
250    ///
251    /// Invariants (cf. module-level docs): title 5..=200 chars, description
252    /// 50..=10_000 chars, deliverables non-empty (each non-empty) <= 50,
253    /// required_signatures non-empty <= 10, attachments <= 20.
254    #[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    /// Build the next version of this spec — keeps the same ACP / building /
369    /// (optionally overridden) deliverables / signatures, increments the
370    /// version and chains via `previous_version_id`. The result is always a
371    /// fresh [`TechnicalSpecStatus::Draft`].
372    ///
373    /// Errors:
374    /// - [`AppError::Validation`] if the new version is not strictly greater
375    ///   than the current one.
376    #[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    /// True iff `new` is strictly greater than `old` (lexicographic on the
407    /// SemVer triple).
408    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    /// True iff bumping from `self.version` to `new` requires every
413    /// previously collected signature to be re-collected. Currently any
414    /// `major` increment triggers this — `minor` / `patch` do not.
415    pub fn requires_resignature(&self, new: &SemVer) -> bool {
416        new.major > self.version.major
417    }
418
419    /// Transition Draft -> PendingSignatures. Idempotent on PendingSignatures.
420    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    /// Promote to Approved once every required signature has been collected.
435    /// Caller is responsible for verifying the signature set actually covers
436    /// `required_signatures`.
437    pub fn mark_approved(&mut self) {
438        self.status = TechnicalSpecStatus::Approved;
439        self.updated_at = Utc::now();
440    }
441
442    /// Whether all required signatures are present in `collected_roles`.
443    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// ============================================================================
451// TechnicalSpecSignature — append-only (pattern Story 3.7)
452// ============================================================================
453
454#[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        // Mandataire roles require a Mandate (caller verifies the Mandate is
477        // active; we only check the presence of the link here).
478        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// ============================================================================
493// Tests — taxonomie 4 categories obligatoire (CRITICAL.md #3, Story 3.8)
494// ============================================================================
495
496#[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        // 60+ chars to be safely above MIN_DESCRIPTION_LEN.
507        "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    // ---- @happy -------------------------------------------------------------
522
523    #[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    // ---- @edge --------------------------------------------------------------
647
648    #[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        // minor bump
743        assert!(!spec.requires_resignature(&SemVer::new(1, 6, 0)));
744        // patch bump
745        assert!(!spec.requires_resignature(&SemVer::new(1, 5, 8)));
746        // major bump
747        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        // Force the spec to Approved to check the bump still resets.
767        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    // ---- @security ----------------------------------------------------------
778
779    #[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        // Negative numbers cannot parse as u32 -> Validation.
806        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    // ---- @negative ----------------------------------------------------------
857
858    #[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}