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#[derive(Debug, Deserialize)]
14pub struct SubmitOptimisationTaskDto {
15 pub building_id: Uuid,
16 pub owner_id: Uuid,
18 pub organization_id: Uuid,
19 pub simulation_months: u32,
20}
21
22#[derive(Debug, Serialize)]
24pub struct GridTaskResponseDto {
25 pub task_id: String,
26 pub message: String,
27}
28
29pub 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 #[instrument(skip(self))]
55 pub async fn submit_optimisation_task(
56 &self,
57 dto: SubmitOptimisationTaskDto,
58 ) -> Result<GridTaskResponseDto, String> {
59 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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}