Skip to main content

koprogo_api/application/use_cases/
technical_spec_use_cases.rs

1//! Use cases for [`TechnicalSpec`] + signatures (Story 3.8 — FR33).
2//!
3//! Operations:
4//!
5//! 1. [`TechnicalSpecUseCases::create_spec`] — syndic creates a new spec in
6//!    `Draft`. Caller (handler) MUST already have enforced the syndic /
7//!    superadmin role check.
8//! 2. [`TechnicalSpecUseCases::bump_version`] — create a new version from a
9//!    previous spec. Always returns a fresh `Draft`; the version must be
10//!    strictly greater than the previous one. The use case marks the
11//!    previous spec as `Superseded` once the bump succeeds.
12//! 3. [`TechnicalSpecUseCases::submit_for_signatures`] — Draft -> Pending.
13//! 4. [`TechnicalSpecUseCases::sign_spec`] — record a signature, returning
14//!    409 on duplicate, 403 when the signatory role is not in the
15//!    `required_signatures` list. When the new signature completes the
16//!    required set, the spec auto-promotes to `Approved`.
17//!
18//! Mandate verification for AMO / Lawyer / Architect roles is handled at
19//! the entity level ([`TechnicalSpecSignature::new`] returns
20//! `SignatoryNotAuthorized` when `mandate_id` is missing for a role that
21//! requires it). Real cryptographic e-signatures and Mandate-active checks
22//! against the [`crate::application::use_cases::MandateUseCases`] are
23//! follow-ups tracked in the Story 3.8 acceptance notes.
24
25use crate::application::error::AppError;
26use crate::application::ports::TechnicalSpecRepository;
27use crate::domain::entities::{
28    SemVer, SignatoryRole, TechnicalSpec, TechnicalSpecSignature, TechnicalSpecStatus,
29};
30use std::sync::Arc;
31use uuid::Uuid;
32
33pub struct TechnicalSpecUseCases {
34    repo: Arc<dyn TechnicalSpecRepository>,
35}
36
37impl TechnicalSpecUseCases {
38    pub fn new(repo: Arc<dyn TechnicalSpecRepository>) -> Self {
39        Self { repo }
40    }
41
42    #[allow(clippy::too_many_arguments)]
43    pub async fn create_spec(
44        &self,
45        acp_id: Uuid,
46        building_id: Option<Uuid>,
47        title: String,
48        description: String,
49        version: SemVer,
50        deliverables: Vec<String>,
51        required_signatures: Vec<SignatoryRole>,
52        attachments: Vec<String>,
53        created_by: Uuid,
54    ) -> Result<TechnicalSpec, AppError> {
55        let spec = TechnicalSpec::new(
56            acp_id,
57            building_id,
58            title,
59            description,
60            version,
61            deliverables,
62            required_signatures,
63            attachments,
64            None,
65            created_by,
66        )?;
67        self.repo.save(&spec).await?;
68        Ok(spec)
69    }
70
71    /// Create a new version of an existing spec. The previous spec is moved
72    /// to `Superseded` so list views can flag the live version. The returned
73    /// spec is always a fresh `Draft`.
74    pub async fn bump_version(
75        &self,
76        previous_spec_id: Uuid,
77        new_version: SemVer,
78        new_title: Option<String>,
79        new_description: Option<String>,
80        new_deliverables: Option<Vec<String>>,
81        new_required_signatures: Option<Vec<SignatoryRole>>,
82        new_attachments: Option<Vec<String>>,
83    ) -> Result<TechnicalSpec, AppError> {
84        let prev = self
85            .repo
86            .find_by_id(previous_spec_id)
87            .await?
88            .ok_or_else(|| AppError::NotFound(format!("technical_spec {}", previous_spec_id)))?;
89
90        let new_spec = prev.bump(
91            new_version,
92            new_title,
93            new_description,
94            new_deliverables,
95            new_required_signatures,
96            new_attachments,
97        )?;
98        self.repo.save(&new_spec).await?;
99        // Mark previous as Superseded (best-effort: the bump itself already
100        // succeeded; subsequent failure to flip the prior status is logged
101        // by the caller, but the new draft is the source of truth).
102        self.repo
103            .update_status(
104                prev.id,
105                &TechnicalSpecStatus::Superseded.to_string(),
106                chrono::Utc::now(),
107            )
108            .await?;
109
110        Ok(new_spec)
111    }
112
113    /// Draft -> PendingSignatures.
114    pub async fn submit_for_signatures(&self, spec_id: Uuid) -> Result<TechnicalSpec, AppError> {
115        let mut spec = self
116            .repo
117            .find_by_id(spec_id)
118            .await?
119            .ok_or_else(|| AppError::NotFound(format!("technical_spec {}", spec_id)))?;
120        spec.submit_for_signatures()?;
121        self.repo
122            .update_status(spec.id, &spec.status.to_string(), spec.updated_at)
123            .await?;
124        Ok(spec)
125    }
126
127    /// Sign a TechnicalSpec.
128    ///
129    /// Guards (in order):
130    /// - spec MUST exist (404 NotFound),
131    /// - spec MUST be in `PendingSignatures` (else `Validation` error: the
132    ///   workflow has not opened the signature window yet),
133    /// - `role` MUST be in `spec.required_signatures` (else `SignatoryNotAuthorized`),
134    /// - Mandataire roles (AMO / Lawyer / Architect) MUST carry a
135    ///   `mandate_id` (entity-level guard surfaces `SignatoryNotAuthorized`),
136    /// - duplicate (spec_id, user, role) tuples return `SignatureAlreadyExists`.
137    ///
138    /// When this signature completes the required set, the spec auto-promotes
139    /// to `Approved` (idempotent at the SQL level: `update_status` is safe to
140    /// repeat).
141    pub async fn sign_spec(
142        &self,
143        spec_id: Uuid,
144        signatory_user_id: Uuid,
145        role: SignatoryRole,
146        mandate_id: Option<Uuid>,
147    ) -> Result<TechnicalSpecSignature, AppError> {
148        let mut spec = self
149            .repo
150            .find_by_id(spec_id)
151            .await?
152            .ok_or_else(|| AppError::NotFound(format!("technical_spec {}", spec_id)))?;
153
154        if !matches!(spec.status, TechnicalSpecStatus::PendingSignatures) {
155            return Err(AppError::Validation(format!(
156                "TechnicalSpec must be PendingSignatures to be signed (is {})",
157                spec.status
158            )));
159        }
160        if !spec.required_signatures.contains(&role) {
161            return Err(AppError::SignatoryNotAuthorized);
162        }
163
164        // Check for duplicate signature on (spec, user, role).
165        let existing = self.repo.list_signatures_for_spec(spec_id).await?;
166        if existing
167            .iter()
168            .any(|s| s.signatory_user_id == signatory_user_id && s.role == role)
169        {
170            return Err(AppError::SignatureAlreadyExists);
171        }
172
173        let sig = TechnicalSpecSignature::new(spec_id, signatory_user_id, role, mandate_id)?;
174        self.repo.save_signature(&sig).await?;
175
176        // Auto-approve when all required roles are now covered.
177        let mut collected: Vec<SignatoryRole> = existing.iter().map(|s| s.role).collect();
178        collected.push(role);
179        if spec.has_all_required_signatures(&collected) {
180            spec.mark_approved();
181            self.repo
182                .update_status(spec.id, &spec.status.to_string(), spec.updated_at)
183                .await?;
184        }
185
186        Ok(sig)
187    }
188
189    pub async fn get(&self, spec_id: Uuid) -> Result<TechnicalSpec, AppError> {
190        self.repo
191            .find_by_id(spec_id)
192            .await?
193            .ok_or_else(|| AppError::NotFound(format!("technical_spec {}", spec_id)))
194    }
195
196    pub async fn list_for_acp(&self, acp_id: Uuid) -> Result<Vec<TechnicalSpec>, AppError> {
197        self.repo.list_for_acp(acp_id).await
198    }
199
200    pub async fn list_signatures_for_spec(
201        &self,
202        spec_id: Uuid,
203    ) -> Result<Vec<TechnicalSpecSignature>, AppError> {
204        self.repo.list_signatures_for_spec(spec_id).await
205    }
206}
207
208// ============================================================================
209// Tests — taxonomie 4 catégories (CRITICAL.md #3, Story 3.8)
210// ============================================================================
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use async_trait::async_trait;
216    use chrono::{DateTime, Utc};
217    use std::collections::HashMap;
218    use std::str::FromStr;
219    use std::sync::Mutex;
220
221    // ── Mock repository ─────────────────────────────────────────────────
222
223    #[derive(Default)]
224    struct InMemoryRepo {
225        specs: Mutex<HashMap<Uuid, TechnicalSpec>>,
226        signatures: Mutex<Vec<TechnicalSpecSignature>>,
227    }
228
229    #[async_trait]
230    impl TechnicalSpecRepository for InMemoryRepo {
231        async fn save(&self, spec: &TechnicalSpec) -> Result<(), AppError> {
232            self.specs.lock().unwrap().insert(spec.id, spec.clone());
233            Ok(())
234        }
235
236        async fn update_status(
237            &self,
238            spec_id: Uuid,
239            status: &str,
240            updated_at: DateTime<Utc>,
241        ) -> Result<(), AppError> {
242            let mut specs = self.specs.lock().unwrap();
243            if let Some(spec) = specs.get_mut(&spec_id) {
244                spec.status = TechnicalSpecStatus::from_str(status)?;
245                spec.updated_at = updated_at;
246            }
247            Ok(())
248        }
249
250        async fn find_by_id(&self, id: Uuid) -> Result<Option<TechnicalSpec>, AppError> {
251            Ok(self.specs.lock().unwrap().get(&id).cloned())
252        }
253
254        async fn list_for_acp(&self, acp_id: Uuid) -> Result<Vec<TechnicalSpec>, AppError> {
255            let specs = self.specs.lock().unwrap();
256            let mut out: Vec<TechnicalSpec> = specs
257                .values()
258                .filter(|s| s.acp_id == acp_id)
259                .cloned()
260                .collect();
261            out.sort_by_key(|s| std::cmp::Reverse(s.created_at));
262            Ok(out)
263        }
264
265        async fn save_signature(&self, sig: &TechnicalSpecSignature) -> Result<(), AppError> {
266            // Mirror the SQL UNIQUE (spec_id, user, role) constraint.
267            let mut sigs = self.signatures.lock().unwrap();
268            if sigs.iter().any(|s| {
269                s.technical_spec_id == sig.technical_spec_id
270                    && s.signatory_user_id == sig.signatory_user_id
271                    && s.role == sig.role
272            }) {
273                return Err(AppError::SignatureAlreadyExists);
274            }
275            sigs.push(sig.clone());
276            Ok(())
277        }
278
279        async fn list_signatures_for_spec(
280            &self,
281            spec_id: Uuid,
282        ) -> Result<Vec<TechnicalSpecSignature>, AppError> {
283            let sigs = self.signatures.lock().unwrap();
284            let mut out: Vec<TechnicalSpecSignature> = sigs
285                .iter()
286                .filter(|s| s.technical_spec_id == spec_id)
287                .cloned()
288                .collect();
289            out.sort_by_key(|s| s.signed_at);
290            Ok(out)
291        }
292    }
293
294    fn make_use_cases() -> (Arc<InMemoryRepo>, TechnicalSpecUseCases) {
295        let repo: Arc<InMemoryRepo> = Arc::new(InMemoryRepo::default());
296        let uc = TechnicalSpecUseCases::new(repo.clone() as Arc<dyn TechnicalSpecRepository>);
297        (repo, uc)
298    }
299
300    fn fixture_description() -> String {
301        "Renovation toiture batiment A : etancheite, isolation 18 cm.".to_string()
302    }
303
304    fn fixture_deliverables() -> Vec<String> {
305        vec![
306            "Plan d'execution".to_string(),
307            "Cahier des charges".to_string(),
308        ]
309    }
310
311    async fn create_pending_spec(
312        uc: &TechnicalSpecUseCases,
313        required: Vec<SignatoryRole>,
314    ) -> TechnicalSpec {
315        let (acp, user) = (Uuid::new_v4(), Uuid::new_v4());
316        let spec = uc
317            .create_spec(
318                acp,
319                None,
320                "Toiture".to_string(),
321                fixture_description(),
322                SemVer::new(1, 0, 0),
323                fixture_deliverables(),
324                required,
325                Vec::new(),
326                user,
327            )
328            .await
329            .expect("create_spec must succeed");
330        uc.submit_for_signatures(spec.id).await.unwrap()
331    }
332
333    // ---- @happy ---------------------------------------------------------
334
335    #[tokio::test]
336    async fn happy_create_submit_then_signatures_complete_approves() {
337        let (_repo, uc) = make_use_cases();
338        let spec = create_pending_spec(
339            &uc,
340            vec![SignatoryRole::Syndic, SignatoryRole::AcpRepresentative],
341        )
342        .await;
343
344        // First sig — still PendingSignatures.
345        let _sig1 = uc
346            .sign_spec(spec.id, Uuid::new_v4(), SignatoryRole::Syndic, None)
347            .await
348            .unwrap();
349        let mid = uc.get(spec.id).await.unwrap();
350        assert_eq!(mid.status, TechnicalSpecStatus::PendingSignatures);
351
352        // Second sig — now Approved.
353        let _sig2 = uc
354            .sign_spec(
355                spec.id,
356                Uuid::new_v4(),
357                SignatoryRole::AcpRepresentative,
358                None,
359            )
360            .await
361            .unwrap();
362        let final_spec = uc.get(spec.id).await.unwrap();
363        assert_eq!(final_spec.status, TechnicalSpecStatus::Approved);
364    }
365
366    #[tokio::test]
367    async fn happy_list_for_acp_returns_newest_first() {
368        let (_repo, uc) = make_use_cases();
369        let acp = Uuid::new_v4();
370        let user = Uuid::new_v4();
371        let _first = uc
372            .create_spec(
373                acp,
374                None,
375                "Toiture".to_string(),
376                fixture_description(),
377                SemVer::new(1, 0, 0),
378                fixture_deliverables(),
379                vec![SignatoryRole::Syndic],
380                Vec::new(),
381                user,
382            )
383            .await
384            .unwrap();
385        // Tiny sleep to make sure created_at ordering is observable.
386        tokio::time::sleep(std::time::Duration::from_millis(2)).await;
387        let _second = uc
388            .create_spec(
389                acp,
390                None,
391                "Facade".to_string(),
392                fixture_description(),
393                SemVer::new(1, 0, 0),
394                fixture_deliverables(),
395                vec![SignatoryRole::Syndic],
396                Vec::new(),
397                user,
398            )
399            .await
400            .unwrap();
401        let listed = uc.list_for_acp(acp).await.unwrap();
402        assert_eq!(listed.len(), 2);
403        assert!(listed[0].created_at >= listed[1].created_at);
404    }
405
406    // ---- @edge ----------------------------------------------------------
407
408    #[tokio::test]
409    async fn edge_bump_minor_keeps_previous_signatures_conceptually() {
410        let (_repo, uc) = make_use_cases();
411        let v1 = create_pending_spec(&uc, vec![SignatoryRole::Syndic]).await;
412        // Approve v1.
413        uc.sign_spec(v1.id, Uuid::new_v4(), SignatoryRole::Syndic, None)
414            .await
415            .unwrap();
416        assert_eq!(
417            uc.get(v1.id).await.unwrap().status,
418            TechnicalSpecStatus::Approved
419        );
420
421        // Minor bump v1.0.0 -> v1.1.0.
422        let v2 = uc
423            .bump_version(v1.id, SemVer::new(1, 1, 0), None, None, None, None, None)
424            .await
425            .unwrap();
426        assert_eq!(v2.status, TechnicalSpecStatus::Draft);
427        assert_eq!(v2.previous_version_id, Some(v1.id));
428        // Previous is now Superseded.
429        assert_eq!(
430            uc.get(v1.id).await.unwrap().status,
431            TechnicalSpecStatus::Superseded
432        );
433        // No new signatures auto-attached: v2 has none yet.
434        assert!(uc.list_signatures_for_spec(v2.id).await.unwrap().is_empty());
435    }
436
437    #[tokio::test]
438    async fn edge_signing_when_spec_not_pending_is_rejected() {
439        let (_repo, uc) = make_use_cases();
440        // Draft spec — not submitted yet.
441        let (acp, user) = (Uuid::new_v4(), Uuid::new_v4());
442        let draft = uc
443            .create_spec(
444                acp,
445                None,
446                "Toiture".to_string(),
447                fixture_description(),
448                SemVer::new(1, 0, 0),
449                fixture_deliverables(),
450                vec![SignatoryRole::Syndic],
451                Vec::new(),
452                user,
453            )
454            .await
455            .unwrap();
456        let err = uc
457            .sign_spec(draft.id, Uuid::new_v4(), SignatoryRole::Syndic, None)
458            .await
459            .unwrap_err();
460        assert!(matches!(err, AppError::Validation(_)));
461    }
462
463    // ---- @security ------------------------------------------------------
464
465    #[tokio::test]
466    async fn security_sign_with_role_not_in_required_set_rejected() {
467        let (_repo, uc) = make_use_cases();
468        let spec = create_pending_spec(&uc, vec![SignatoryRole::Syndic]).await;
469        let err = uc
470            .sign_spec(
471                spec.id,
472                Uuid::new_v4(),
473                SignatoryRole::AcpRepresentative,
474                None,
475            )
476            .await
477            .unwrap_err();
478        assert!(matches!(err, AppError::SignatoryNotAuthorized));
479    }
480
481    #[tokio::test]
482    async fn security_mandataire_role_without_mandate_id_rejected() {
483        let (_repo, uc) = make_use_cases();
484        let spec = create_pending_spec(&uc, vec![SignatoryRole::Lawyer]).await;
485        let err = uc
486            .sign_spec(spec.id, Uuid::new_v4(), SignatoryRole::Lawyer, None)
487            .await
488            .unwrap_err();
489        assert!(matches!(err, AppError::SignatoryNotAuthorized));
490    }
491
492    // ---- @negative ------------------------------------------------------
493
494    #[tokio::test]
495    async fn negative_duplicate_signature_returns_conflict() {
496        let (_repo, uc) = make_use_cases();
497        let spec = create_pending_spec(
498            &uc,
499            vec![SignatoryRole::Syndic, SignatoryRole::AcpRepresentative],
500        )
501        .await;
502        let syndic_user = Uuid::new_v4();
503        uc.sign_spec(spec.id, syndic_user, SignatoryRole::Syndic, None)
504            .await
505            .unwrap();
506        // Same (user, role) — duplicate.
507        let err = uc
508            .sign_spec(spec.id, syndic_user, SignatoryRole::Syndic, None)
509            .await
510            .unwrap_err();
511        assert!(matches!(err, AppError::SignatureAlreadyExists));
512    }
513
514    #[tokio::test]
515    async fn negative_submit_unknown_spec_returns_not_found() {
516        let (_repo, uc) = make_use_cases();
517        let err = uc.submit_for_signatures(Uuid::new_v4()).await.unwrap_err();
518        assert!(matches!(err, AppError::NotFound(_)));
519    }
520
521    #[tokio::test]
522    async fn negative_bump_unknown_spec_returns_not_found() {
523        let (_repo, uc) = make_use_cases();
524        let err = uc
525            .bump_version(
526                Uuid::new_v4(),
527                SemVer::new(2, 0, 0),
528                None,
529                None,
530                None,
531                None,
532                None,
533            )
534            .await
535            .unwrap_err();
536        assert!(matches!(err, AppError::NotFound(_)));
537    }
538
539    #[tokio::test]
540    async fn negative_bump_to_equal_version_rejected_at_entity_level() {
541        let (_repo, uc) = make_use_cases();
542        let spec = create_pending_spec(&uc, vec![SignatoryRole::Syndic]).await;
543        let err = uc
544            .bump_version(spec.id, SemVer::new(1, 0, 0), None, None, None, None, None)
545            .await
546            .unwrap_err();
547        assert!(matches!(err, AppError::Validation(_)));
548    }
549}