1use 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 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 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 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 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 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 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#[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 #[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 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 #[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 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 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 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 #[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 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 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 assert_eq!(
430 uc.get(v1.id).await.unwrap().status,
431 TechnicalSpecStatus::Superseded
432 );
433 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 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 #[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 #[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 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}