koprogo_api/application/use_cases/
boinc_use_cases.rs

1use crate::application::ports::grid_participation_port::{
2    BoincConsent, GridParticipationPort, GridTask, GridTaskId, GridTaskKind, GridTaskStatus,
3};
4use crate::application::ports::iot_repository::IoTRepository;
5use crate::domain::entities::MetricType;
6use chrono::Utc;
7use serde::{Deserialize, Serialize};
8use std::sync::Arc;
9use tracing::{info, instrument};
10use uuid::Uuid;
11
12/// DTO pour soumettre une tâche d'optimisation énergétique groupée.
13#[derive(Debug, Deserialize)]
14pub struct SubmitOptimisationTaskDto {
15    pub building_id: Uuid,
16    /// Propriétaire qui initie la tâche (doit avoir consenti à BOINC)
17    pub owner_id: Uuid,
18    pub organization_id: Uuid,
19    pub simulation_months: u32,
20}
21
22/// DTO de réponse pour une tâche grid soumise.
23#[derive(Debug, Serialize)]
24pub struct GridTaskResponseDto {
25    pub task_id: String,
26    pub message: String,
27}
28
29/// Use cases pour la gestion du calcul distribué BOINC.
30/// Gère le cycle de vie des tâches + consentement GDPR.
31///
32/// Architecture hexagonale: dépend des ports (traits), pas des adapters.
33/// L'adapter concret (BoincGridAdapter) est injecté à la construction.
34pub struct BoincUseCases {
35    grid_port: Arc<dyn GridParticipationPort>,
36    iot_repo: Arc<dyn IoTRepository>,
37}
38
39impl BoincUseCases {
40    pub fn new(
41        grid_port: Arc<dyn GridParticipationPort>,
42        iot_repo: Arc<dyn IoTRepository>,
43    ) -> Self {
44        Self {
45            grid_port,
46            iot_repo,
47        }
48    }
49
50    /// Soumet une tâche d'optimisation énergétique groupée à BOINC.
51    ///
52    /// GDPR: vérifie le consentement explicite AVANT toute soumission.
53    /// Anonymisation: les données kWh sont agrégées (pas de PII dans la tâche).
54    #[instrument(skip(self))]
55    pub async fn submit_optimisation_task(
56        &self,
57        dto: SubmitOptimisationTaskDto,
58    ) -> Result<GridTaskResponseDto, String> {
59        // GDPR: vérification consentement obligatoire
60        let consented = self
61            .grid_port
62            .check_consent(dto.owner_id)
63            .await
64            .map_err(|e| e.to_string())?;
65        if !consented {
66            return Err(format!(
67                "Owner {} has not consented to BOINC participation (GDPR Art. 6.1.a)",
68                dto.owner_id
69            ));
70        }
71
72        // Récupérer stats agrégées anonymisées (pas de PII) — 12 mois glissants
73        let end = Utc::now();
74        let start = end - chrono::Duration::days(365);
75        let stats = self
76            .iot_repo
77            .get_consumption_stats(
78                dto.building_id,
79                MetricType::ElectricityConsumption,
80                start,
81                end,
82            )
83            .await
84            .map_err(|e| e.to_string())?;
85        let anonymised_json =
86            serde_json::to_string(&stats).map_err(|e| format!("Serialization error: {e}"))?;
87
88        let task = GridTask {
89            internal_id: Uuid::new_v4(),
90            copropriete_id: dto.building_id,
91            organization_id: dto.organization_id,
92            kind: GridTaskKind::EnergyGroupOptimisation {
93                building_id: dto.building_id,
94                anonymised_readings_json: anonymised_json,
95                simulation_months: dto.simulation_months,
96            },
97            priority: 5,
98            deadline: Utc::now() + chrono::Duration::hours(24),
99        };
100
101        let task_id = self
102            .grid_port
103            .submit_task(task)
104            .await
105            .map_err(|e| e.to_string())?;
106
107        info!("BOINC task submitted: {}", task_id.0);
108        Ok(GridTaskResponseDto {
109            task_id: task_id.0,
110            message: "Optimisation task submitted to BOINC grid".to_string(),
111        })
112    }
113
114    /// Accorde le consentement BOINC pour un propriétaire (GDPR Art. 7).
115    pub async fn grant_consent(
116        &self,
117        owner_id: Uuid,
118        org_id: Uuid,
119        ip: Option<&str>,
120    ) -> Result<BoincConsent, String> {
121        self.grid_port
122            .grant_consent(owner_id, org_id, "v1.0", ip)
123            .await
124            .map_err(|e| e.to_string())
125    }
126
127    /// Révoque le consentement BOINC (GDPR Art. 7.3 - droit de retrait).
128    pub async fn revoke_consent(&self, owner_id: Uuid) -> Result<(), String> {
129        self.grid_port
130            .revoke_consent(owner_id)
131            .await
132            .map_err(|e| e.to_string())
133    }
134
135    /// Récupère le consentement courant d'un propriétaire.
136    pub async fn get_consent(&self, owner_id: Uuid) -> Result<Option<BoincConsent>, String> {
137        self.grid_port
138            .get_consent(owner_id)
139            .await
140            .map_err(|e| e.to_string())
141    }
142
143    /// Interroge le statut d'une tâche BOINC.
144    pub async fn poll_task(&self, task_id: &str) -> Result<GridTaskStatus, String> {
145        let id = GridTaskId(task_id.to_string());
146        self.grid_port
147            .poll_result(&id)
148            .await
149            .map_err(|e| e.to_string())
150    }
151
152    /// Annule une tâche BOINC en cours.
153    pub async fn cancel_task(&self, task_id: &str) -> Result<(), String> {
154        let id = GridTaskId(task_id.to_string());
155        self.grid_port
156            .cancel_task(&id)
157            .await
158            .map_err(|e| e.to_string())
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::application::dto::{ConsumptionStatsDto, DailyAggregateDto, MonthlyAggregateDto};
166    use crate::application::ports::grid_participation_port::{
167        BoincConsent, GridError, GridParticipationPort, GridTask, GridTaskId, GridTaskStatus,
168    };
169    use crate::application::ports::iot_repository::IoTRepository;
170    use crate::domain::entities::{DeviceType, IoTReading, LinkyDevice, MetricType};
171    use async_trait::async_trait;
172    use chrono::{DateTime, Utc};
173    use std::collections::HashMap;
174    use std::sync::{Arc, Mutex};
175    use uuid::Uuid;
176
177    // ── Mock GridParticipationPort ──────────────────────────────────────────
178    struct MockGridPort {
179        consents: Mutex<HashMap<Uuid, BoincConsent>>,
180        tasks: Mutex<HashMap<String, GridTaskStatus>>,
181    }
182
183    impl MockGridPort {
184        fn new() -> Self {
185            Self {
186                consents: Mutex::new(HashMap::new()),
187                tasks: Mutex::new(HashMap::new()),
188            }
189        }
190    }
191
192    #[async_trait]
193    impl GridParticipationPort for MockGridPort {
194        async fn check_consent(&self, owner_id: Uuid) -> Result<bool, GridError> {
195            let map = self.consents.lock().unwrap();
196            Ok(map.get(&owner_id).map_or(false, |c| c.granted))
197        }
198
199        async fn get_consent(&self, owner_id: Uuid) -> Result<Option<BoincConsent>, GridError> {
200            let map = self.consents.lock().unwrap();
201            Ok(map.get(&owner_id).cloned())
202        }
203
204        async fn grant_consent(
205            &self,
206            owner_id: Uuid,
207            organization_id: Uuid,
208            consent_version: &str,
209            consent_ip: Option<&str>,
210        ) -> Result<BoincConsent, GridError> {
211            let consent = BoincConsent {
212                owner_id,
213                organization_id,
214                granted: true,
215                granted_at: Some(Utc::now()),
216                revoked_at: None,
217                consent_ip: consent_ip.map(|s| s.to_string()),
218                consent_version: consent_version.to_string(),
219            };
220            self.consents
221                .lock()
222                .unwrap()
223                .insert(owner_id, consent.clone());
224            Ok(consent)
225        }
226
227        async fn revoke_consent(&self, owner_id: Uuid) -> Result<(), GridError> {
228            let mut map = self.consents.lock().unwrap();
229            if let Some(consent) = map.get_mut(&owner_id) {
230                consent.granted = false;
231                consent.revoked_at = Some(Utc::now());
232                Ok(())
233            } else {
234                Err(GridError::ConsentNotGranted(owner_id))
235            }
236        }
237
238        async fn submit_task(&self, _task: GridTask) -> Result<GridTaskId, GridError> {
239            let task_id = GridTaskId(Uuid::new_v4().to_string());
240            self.tasks
241                .lock()
242                .unwrap()
243                .insert(task_id.0.clone(), GridTaskStatus::Queued);
244            Ok(task_id)
245        }
246
247        async fn poll_result(&self, task_id: &GridTaskId) -> Result<GridTaskStatus, GridError> {
248            let map = self.tasks.lock().unwrap();
249            map.get(&task_id.0)
250                .cloned()
251                .ok_or_else(|| GridError::TaskNotFound(task_id.0.clone()))
252        }
253
254        async fn cancel_task(&self, task_id: &GridTaskId) -> Result<(), GridError> {
255            let mut map = self.tasks.lock().unwrap();
256            if map.contains_key(&task_id.0) {
257                map.insert(task_id.0.clone(), GridTaskStatus::Cancelled);
258                Ok(())
259            } else {
260                Err(GridError::TaskNotFound(task_id.0.clone()))
261            }
262        }
263    }
264
265    // ── Mock IoTRepository (minimal, only needed for constructor) ───────────
266    struct MockIoTRepo;
267
268    #[async_trait]
269    impl IoTRepository for MockIoTRepo {
270        async fn create_reading(&self, _r: &IoTReading) -> Result<IoTReading, String> {
271            Err("not impl".to_string())
272        }
273        async fn create_readings_bulk(&self, _r: &[IoTReading]) -> Result<usize, String> {
274            Err("not impl".to_string())
275        }
276        async fn find_readings_by_building(
277            &self,
278            _b: Uuid,
279            _dt: Option<DeviceType>,
280            _mt: Option<MetricType>,
281            _s: DateTime<Utc>,
282            _e: DateTime<Utc>,
283            _l: Option<usize>,
284        ) -> Result<Vec<IoTReading>, String> {
285            Ok(vec![])
286        }
287        async fn get_consumption_stats(
288            &self,
289            building_id: Uuid,
290            mt: MetricType,
291            s: DateTime<Utc>,
292            e: DateTime<Utc>,
293        ) -> Result<ConsumptionStatsDto, String> {
294            Ok(ConsumptionStatsDto {
295                building_id,
296                metric_type: mt,
297                period_start: s,
298                period_end: e,
299                total_consumption: 0.0,
300                average_daily: 0.0,
301                min_value: 0.0,
302                max_value: 0.0,
303                reading_count: 0,
304                unit: "kWh".to_string(),
305                source: "mock".to_string(),
306            })
307        }
308        async fn get_daily_aggregates(
309            &self,
310            _b: Uuid,
311            _dt: DeviceType,
312            _mt: MetricType,
313            _s: DateTime<Utc>,
314            _e: DateTime<Utc>,
315        ) -> Result<Vec<DailyAggregateDto>, String> {
316            Ok(vec![])
317        }
318        async fn get_monthly_aggregates(
319            &self,
320            _b: Uuid,
321            _dt: DeviceType,
322            _mt: MetricType,
323            _s: DateTime<Utc>,
324            _e: DateTime<Utc>,
325        ) -> Result<Vec<MonthlyAggregateDto>, String> {
326            Ok(vec![])
327        }
328        async fn detect_anomalies(
329            &self,
330            _b: Uuid,
331            _mt: MetricType,
332            _t: f64,
333            _d: i64,
334        ) -> Result<Vec<IoTReading>, String> {
335            Ok(vec![])
336        }
337        async fn create_linky_device(&self, _d: &LinkyDevice) -> Result<LinkyDevice, String> {
338            Err("not impl".to_string())
339        }
340        async fn find_linky_device_by_id(&self, _id: Uuid) -> Result<Option<LinkyDevice>, String> {
341            Ok(None)
342        }
343        async fn find_linky_device_by_building(
344            &self,
345            _b: Uuid,
346        ) -> Result<Option<LinkyDevice>, String> {
347            Ok(None)
348        }
349        async fn find_linky_device_by_prm(
350            &self,
351            _p: &str,
352            _pr: &str,
353        ) -> Result<Option<LinkyDevice>, String> {
354            Ok(None)
355        }
356        async fn update_linky_device(&self, _d: &LinkyDevice) -> Result<LinkyDevice, String> {
357            Err("not impl".to_string())
358        }
359        async fn delete_linky_device(&self, _id: Uuid) -> Result<(), String> {
360            Ok(())
361        }
362        async fn find_devices_needing_sync(&self) -> Result<Vec<LinkyDevice>, String> {
363            Ok(vec![])
364        }
365        async fn find_devices_with_expired_tokens(&self) -> Result<Vec<LinkyDevice>, String> {
366            Ok(vec![])
367        }
368    }
369
370    // ── Helpers ─────────────────────────────────────────────────────────────
371    fn setup() -> (BoincUseCases, Arc<MockGridPort>) {
372        let grid_port = Arc::new(MockGridPort::new());
373        let iot_repo = Arc::new(MockIoTRepo);
374
375        let uc = BoincUseCases::new(
376            grid_port.clone() as Arc<dyn GridParticipationPort>,
377            iot_repo as Arc<dyn IoTRepository>,
378        );
379
380        (uc, grid_port)
381    }
382
383    // ── Tests ───────────────────────────────────────────────────────────────
384
385    #[tokio::test]
386    async fn test_grant_consent_success() {
387        let (uc, _) = setup();
388        let owner_id = Uuid::new_v4();
389        let org_id = Uuid::new_v4();
390
391        let result = uc.grant_consent(owner_id, org_id, Some("127.0.0.1")).await;
392        assert!(result.is_ok());
393        let consent = result.unwrap();
394        assert!(consent.granted);
395        assert_eq!(consent.owner_id, owner_id);
396        assert_eq!(consent.organization_id, org_id);
397        assert_eq!(consent.consent_version, "v1.0");
398        assert_eq!(consent.consent_ip, Some("127.0.0.1".to_string()));
399    }
400
401    #[tokio::test]
402    async fn test_get_consent_after_grant() {
403        let (uc, _) = setup();
404        let owner_id = Uuid::new_v4();
405        let org_id = Uuid::new_v4();
406
407        uc.grant_consent(owner_id, org_id, None).await.unwrap();
408
409        let result = uc.get_consent(owner_id).await;
410        assert!(result.is_ok());
411        let consent = result.unwrap();
412        assert!(consent.is_some());
413        assert!(consent.unwrap().granted);
414    }
415
416    #[tokio::test]
417    async fn test_get_consent_not_granted() {
418        let (uc, _) = setup();
419        let owner_id = Uuid::new_v4();
420
421        let result = uc.get_consent(owner_id).await;
422        assert!(result.is_ok());
423        assert!(result.unwrap().is_none());
424    }
425
426    #[tokio::test]
427    async fn test_revoke_consent_success() {
428        let (uc, _) = setup();
429        let owner_id = Uuid::new_v4();
430        let org_id = Uuid::new_v4();
431
432        uc.grant_consent(owner_id, org_id, None).await.unwrap();
433
434        let result = uc.revoke_consent(owner_id).await;
435        assert!(result.is_ok());
436
437        // Check it's revoked
438        let consent = uc.get_consent(owner_id).await.unwrap().unwrap();
439        assert!(!consent.granted);
440        assert!(consent.revoked_at.is_some());
441    }
442
443    #[tokio::test]
444    async fn test_revoke_consent_not_granted_fails() {
445        let (uc, _) = setup();
446        let owner_id = Uuid::new_v4();
447
448        let result = uc.revoke_consent(owner_id).await;
449        assert!(result.is_err());
450        assert!(result.unwrap_err().contains("Consent not granted"));
451    }
452
453    #[tokio::test]
454    async fn test_poll_task_queued() {
455        let (uc, grid_port) = setup();
456
457        // Manually insert a task in Queued state
458        let task_id = "test-task-123".to_string();
459        grid_port
460            .tasks
461            .lock()
462            .unwrap()
463            .insert(task_id.clone(), GridTaskStatus::Queued);
464
465        let result = uc.poll_task(&task_id).await;
466        assert!(result.is_ok());
467        assert_eq!(result.unwrap(), GridTaskStatus::Queued);
468    }
469
470    #[tokio::test]
471    async fn test_poll_task_not_found() {
472        let (uc, _) = setup();
473        let result = uc.poll_task("nonexistent-task").await;
474        assert!(result.is_err());
475        assert!(result.unwrap_err().contains("Task not found"));
476    }
477
478    #[tokio::test]
479    async fn test_cancel_task_success() {
480        let (uc, grid_port) = setup();
481
482        let task_id = "task-to-cancel".to_string();
483        grid_port
484            .tasks
485            .lock()
486            .unwrap()
487            .insert(task_id.clone(), GridTaskStatus::Queued);
488
489        let result = uc.cancel_task(&task_id).await;
490        assert!(result.is_ok());
491
492        // Verify it's cancelled
493        let status = uc.poll_task(&task_id).await.unwrap();
494        assert_eq!(status, GridTaskStatus::Cancelled);
495    }
496
497    #[tokio::test]
498    async fn test_cancel_task_not_found() {
499        let (uc, _) = setup();
500        let result = uc.cancel_task("nonexistent").await;
501        assert!(result.is_err());
502        assert!(result.unwrap_err().contains("Task not found"));
503    }
504}