koprogo_api/application/use_cases/
mandate_use_cases.rs1use crate::application::error::AppError;
22use crate::application::ports::MandateRepository;
23use crate::domain::entities::{Mandate, MandateKind, MandateScope};
24use chrono::{DateTime, Utc};
25use std::sync::Arc;
26use uuid::Uuid;
27
28pub struct MandateUseCases {
29 repo: Arc<dyn MandateRepository>,
30}
31
32impl MandateUseCases {
33 pub fn new(repo: Arc<dyn MandateRepository>) -> Self {
34 Self { repo }
35 }
36
37 #[allow(clippy::too_many_arguments)]
40 pub async fn issue(
41 &self,
42 subject_user_id: Uuid,
43 kind: MandateKind,
44 scope: MandateScope,
45 issued_by: Uuid,
46 reason: String,
47 valid_from: DateTime<Utc>,
48 valid_until: DateTime<Utc>,
49 ) -> Result<Mandate, AppError> {
50 let mandate = Mandate::issue(
51 subject_user_id,
52 kind,
53 scope,
54 issued_by,
55 reason,
56 valid_from,
57 valid_until,
58 )?;
59 self.repo.save(&mandate).await?;
60 Ok(mandate)
61 }
62
63 pub async fn assert_mandate_authorizes(
73 &self,
74 subject_user_id: Uuid,
75 kind: MandateKind,
76 scope: &MandateScope,
77 ) -> Result<Mandate, AppError> {
78 let mandates = self.repo.list_active_for_subject(subject_user_id).await?;
79
80 let same_kind: Vec<&Mandate> = mandates.iter().filter(|m| m.kind == kind).collect();
82 if same_kind.is_empty() {
83 return Err(AppError::MandateNotFound);
84 }
85
86 let matching_scope = same_kind.iter().find(|m| m.scope == *scope);
88 let Some(mandate) = matching_scope else {
89 return Err(AppError::MandateInvalidScope);
90 };
91
92 if mandate.is_revoked() {
93 return Err(AppError::MandateRevoked);
94 }
95 if mandate.is_expired() {
96 return Err(AppError::MandateExpired);
97 }
98 if !mandate.is_currently_active() {
101 return Err(AppError::MandateExpired);
102 }
103
104 Ok((*mandate).clone())
105 }
106
107 pub async fn revoke(&self, id: Uuid) -> Result<(), AppError> {
110 let mandate = self
112 .repo
113 .find_by_id(id)
114 .await?
115 .ok_or(AppError::MandateNotFound)?;
116 if mandate.is_revoked() {
117 return Ok(());
120 }
121 self.repo.revoke(id, Utc::now()).await?;
122 Ok(())
123 }
124
125 pub async fn get(&self, id: Uuid) -> Result<Mandate, AppError> {
126 self.repo
127 .find_by_id(id)
128 .await?
129 .ok_or(AppError::MandateNotFound)
130 }
131
132 pub async fn list_active_for_subject(
133 &self,
134 subject_user_id: Uuid,
135 ) -> Result<Vec<Mandate>, AppError> {
136 self.repo.list_active_for_subject(subject_user_id).await
137 }
138
139 pub async fn list_for_scope(&self, scope: &MandateScope) -> Result<Vec<Mandate>, AppError> {
140 self.repo.list_for_scope(scope).await
141 }
142}
143
144#[cfg(test)]
149mod tests {
150 use super::*;
151 use async_trait::async_trait;
152 use chrono::Duration;
153 use std::sync::Mutex;
154
155 #[derive(Default)]
156 struct InMemoryRepo {
157 rows: Mutex<Vec<Mandate>>,
158 }
159
160 #[async_trait]
161 impl MandateRepository for InMemoryRepo {
162 async fn save(&self, m: &Mandate) -> Result<(), AppError> {
163 self.rows.lock().unwrap().push(m.clone());
164 Ok(())
165 }
166
167 async fn find_by_id(&self, id: Uuid) -> Result<Option<Mandate>, AppError> {
168 Ok(self
169 .rows
170 .lock()
171 .unwrap()
172 .iter()
173 .find(|m| m.id == id)
174 .cloned())
175 }
176
177 async fn list_active_for_subject(
178 &self,
179 subject_user_id: Uuid,
180 ) -> Result<Vec<Mandate>, AppError> {
181 Ok(self
184 .rows
185 .lock()
186 .unwrap()
187 .iter()
188 .filter(|m| m.subject_user_id == subject_user_id && m.is_currently_active())
189 .cloned()
190 .collect())
191 }
192
193 async fn list_for_scope(&self, scope: &MandateScope) -> Result<Vec<Mandate>, AppError> {
194 Ok(self
195 .rows
196 .lock()
197 .unwrap()
198 .iter()
199 .filter(|m| m.scope == *scope)
200 .cloned()
201 .collect())
202 }
203
204 async fn revoke(&self, id: Uuid, revoked_at: DateTime<Utc>) -> Result<(), AppError> {
205 let mut rows = self.rows.lock().unwrap();
206 if let Some(row) = rows.iter_mut().find(|m| m.id == id) {
207 if row.revoked_at.is_none() {
208 row.revoked_at = Some(revoked_at);
209 row.updated_at = revoked_at;
210 }
211 }
212 Ok(())
213 }
214 }
215
216 fn factory() -> (Arc<InMemoryRepo>, MandateUseCases) {
217 let repo: Arc<InMemoryRepo> = Arc::new(InMemoryRepo::default());
218 let uc = MandateUseCases::new(repo.clone() as Arc<dyn MandateRepository>);
219 (repo, uc)
220 }
221
222 fn fixture_window() -> (DateTime<Utc>, DateTime<Utc>) {
223 let now = Utc::now();
224 (now - Duration::seconds(1), now + Duration::days(30))
225 }
226
227 fn reason() -> String {
228 "Cession unité — mandat notarial".to_string()
229 }
230
231 #[tokio::test]
234 async fn happy_issue_then_authorize_returns_mandate() {
235 let (_repo, uc) = factory();
236 let subject = Uuid::new_v4();
237 let issuer = Uuid::new_v4();
238 let building = Uuid::new_v4();
239 let scope = MandateScope::Building(building);
240 let (from, until) = fixture_window();
241
242 let issued = uc
243 .issue(
244 subject,
245 MandateKind::Notary,
246 scope,
247 issuer,
248 reason(),
249 from,
250 until,
251 )
252 .await
253 .unwrap();
254 assert_eq!(issued.subject_user_id, subject);
255
256 let resolved = uc
257 .assert_mandate_authorizes(subject, MandateKind::Notary, &scope)
258 .await
259 .unwrap();
260 assert_eq!(resolved.id, issued.id);
261 }
262
263 #[tokio::test]
264 async fn happy_revoke_then_authorize_reports_revoked() {
265 let (_repo, uc) = factory();
266 let subject = Uuid::new_v4();
267 let issuer = Uuid::new_v4();
268 let scope = MandateScope::Acp(Uuid::new_v4());
269 let (from, until) = fixture_window();
270
271 let issued = uc
272 .issue(
273 subject,
274 MandateKind::Lawyer,
275 scope,
276 issuer,
277 reason(),
278 from,
279 until,
280 )
281 .await
282 .unwrap();
283 uc.revoke(issued.id).await.unwrap();
284
285 let err = uc
286 .assert_mandate_authorizes(subject, MandateKind::Lawyer, &scope)
287 .await
288 .unwrap_err();
289 assert!(
290 matches!(err, AppError::MandateNotFound | AppError::MandateRevoked),
291 "expected revoked-related error, got {:?}",
292 err
293 );
294 }
295
296 #[tokio::test]
299 async fn edge_double_revoke_is_idempotent() {
300 let (_repo, uc) = factory();
301 let subject = Uuid::new_v4();
302 let issuer = Uuid::new_v4();
303 let scope = MandateScope::Building(Uuid::new_v4());
304 let (from, until) = fixture_window();
305
306 let issued = uc
307 .issue(
308 subject,
309 MandateKind::Bet,
310 scope,
311 issuer,
312 reason(),
313 from,
314 until,
315 )
316 .await
317 .unwrap();
318 uc.revoke(issued.id).await.unwrap();
319 uc.revoke(issued.id).await.unwrap();
321 }
322
323 #[tokio::test]
324 async fn edge_get_unknown_id_returns_not_found() {
325 let (_repo, uc) = factory();
326 let err = uc.get(Uuid::new_v4()).await.unwrap_err();
327 assert!(matches!(err, AppError::MandateNotFound));
328 }
329
330 #[tokio::test]
333 async fn security_wrong_scope_returns_invalid_scope() {
334 let (_repo, uc) = factory();
335 let subject = Uuid::new_v4();
336 let issuer = Uuid::new_v4();
337 let building_a = MandateScope::Building(Uuid::new_v4());
338 let building_b = MandateScope::Building(Uuid::new_v4());
339 let (from, until) = fixture_window();
340
341 uc.issue(
342 subject,
343 MandateKind::Notary,
344 building_a,
345 issuer,
346 reason(),
347 from,
348 until,
349 )
350 .await
351 .unwrap();
352
353 let err = uc
354 .assert_mandate_authorizes(subject, MandateKind::Notary, &building_b)
355 .await
356 .unwrap_err();
357 assert!(
358 matches!(err, AppError::MandateInvalidScope),
359 "expected MandateInvalidScope, got {:?}",
360 err
361 );
362 }
363
364 #[tokio::test]
365 async fn security_subject_equals_issuer_blocked_at_entity_level() {
366 let (_repo, uc) = factory();
367 let same = Uuid::new_v4();
368 let (from, until) = fixture_window();
369 let err = uc
370 .issue(
371 same,
372 MandateKind::Notary,
373 MandateScope::Building(Uuid::new_v4()),
374 same,
375 reason(),
376 from,
377 until,
378 )
379 .await
380 .unwrap_err();
381 assert!(matches!(err, AppError::Validation(_)));
382 }
383
384 #[tokio::test]
385 async fn security_kind_mismatch_returns_not_found() {
386 let (_repo, uc) = factory();
387 let subject = Uuid::new_v4();
388 let issuer = Uuid::new_v4();
389 let scope = MandateScope::Building(Uuid::new_v4());
390 let (from, until) = fixture_window();
391 uc.issue(
392 subject,
393 MandateKind::Notary,
394 scope,
395 issuer,
396 reason(),
397 from,
398 until,
399 )
400 .await
401 .unwrap();
402 let err = uc
404 .assert_mandate_authorizes(subject, MandateKind::Lawyer, &scope)
405 .await
406 .unwrap_err();
407 assert!(matches!(err, AppError::MandateNotFound));
408 }
409
410 #[tokio::test]
413 async fn negative_expired_mandate_returns_mandate_expired() {
414 let repo = Arc::new(InMemoryRepo::default());
417 let subject = Uuid::new_v4();
418 let scope = MandateScope::Acp(Uuid::new_v4());
419 let now = Utc::now();
420 let m = Mandate {
421 id: Uuid::new_v4(),
422 subject_user_id: subject,
423 kind: MandateKind::Architect,
424 scope,
425 issued_by: Uuid::new_v4(),
426 reason: "Devis travaux toiture - dossier 2025".to_string(),
427 valid_from: now - Duration::days(40),
428 valid_until: now - Duration::days(1),
429 revoked_at: None,
430 created_at: now - Duration::days(40),
431 updated_at: now - Duration::days(40),
432 };
433 repo.rows.lock().unwrap().push(m);
434 let uc = MandateUseCases::new(repo.clone() as Arc<dyn MandateRepository>);
435
436 let err = uc
437 .assert_mandate_authorizes(subject, MandateKind::Architect, &scope)
438 .await
439 .unwrap_err();
440 assert!(
443 matches!(err, AppError::MandateNotFound | AppError::MandateExpired),
444 "expected MandateNotFound/Expired, got {:?}",
445 err
446 );
447 }
448
449 #[tokio::test]
450 async fn negative_revoke_unknown_id_returns_not_found() {
451 let (_repo, uc) = factory();
452 let err = uc.revoke(Uuid::new_v4()).await.unwrap_err();
453 assert!(matches!(err, AppError::MandateNotFound));
454 }
455
456 #[tokio::test]
457 async fn negative_short_reason_is_rejected_at_use_case_boundary() {
458 let (_repo, uc) = factory();
459 let (from, until) = fixture_window();
460 let err = uc
461 .issue(
462 Uuid::new_v4(),
463 MandateKind::Warden,
464 MandateScope::Building(Uuid::new_v4()),
465 Uuid::new_v4(),
466 "x".to_string(),
467 from,
468 until,
469 )
470 .await
471 .unwrap_err();
472 assert!(matches!(err, AppError::Validation(_)));
473 }
474}