koprogo_api/domain/entities/
linky_device.rs1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
8pub struct LinkyDevice {
9 pub id: Uuid,
10 pub building_id: Uuid,
11 pub prm: String, pub provider: LinkyProvider,
13 pub api_key_encrypted: String, pub refresh_token_encrypted: Option<String>, pub token_expires_at: Option<DateTime<Utc>>,
16 pub last_sync_at: Option<DateTime<Utc>>,
17 pub sync_enabled: bool,
18 pub created_at: DateTime<Utc>,
19 pub updated_at: DateTime<Utc>,
20}
21
22impl LinkyDevice {
23 pub fn new(
25 building_id: Uuid,
26 prm: String,
27 provider: LinkyProvider,
28 api_key_encrypted: String,
29 ) -> Result<Self, String> {
30 Self::validate_prm(&prm)?;
32
33 if api_key_encrypted.trim().is_empty() {
35 return Err("API key cannot be empty".to_string());
36 }
37
38 Ok(Self {
39 id: Uuid::new_v4(),
40 building_id,
41 prm,
42 provider,
43 api_key_encrypted,
44 refresh_token_encrypted: None,
45 token_expires_at: None,
46 last_sync_at: None,
47 sync_enabled: true,
48 created_at: Utc::now(),
49 updated_at: Utc::now(),
50 })
51 }
52
53 pub fn with_refresh_token(
55 mut self,
56 refresh_token_encrypted: String,
57 expires_at: DateTime<Utc>,
58 ) -> Self {
59 self.refresh_token_encrypted = Some(refresh_token_encrypted);
60 self.token_expires_at = Some(expires_at);
61 self
62 }
63
64 pub fn set_sync_enabled(&mut self, enabled: bool) {
66 self.sync_enabled = enabled;
67 self.updated_at = Utc::now();
68 }
69
70 pub fn enable_sync(&mut self) {
72 self.set_sync_enabled(true);
73 }
74
75 pub fn disable_sync(&mut self) {
77 self.set_sync_enabled(false);
78 }
79
80 pub fn mark_synced(&mut self) {
82 self.last_sync_at = Some(Utc::now());
83 self.updated_at = Utc::now();
84 }
85
86 pub fn update_tokens(
88 &mut self,
89 api_key_encrypted: String,
90 refresh_token_encrypted: Option<String>,
91 expires_at: Option<DateTime<Utc>>,
92 ) -> Result<(), String> {
93 if api_key_encrypted.trim().is_empty() {
94 return Err("API key cannot be empty".to_string());
95 }
96
97 self.api_key_encrypted = api_key_encrypted;
98 self.refresh_token_encrypted = refresh_token_encrypted;
99 self.token_expires_at = expires_at;
100 self.updated_at = Utc::now();
101
102 Ok(())
103 }
104
105 pub fn is_token_expired(&self) -> bool {
107 match self.token_expires_at {
108 Some(expires_at) => expires_at <= Utc::now() + chrono::Duration::minutes(5),
109 None => false, }
111 }
112
113 pub fn needs_sync(&self) -> bool {
115 if !self.sync_enabled {
116 return false;
117 }
118
119 match self.last_sync_at {
120 Some(last_sync) => {
121 let hours_since_sync = (Utc::now() - last_sync).num_hours();
122 hours_since_sync >= 24
123 }
124 None => true, }
126 }
127
128 fn validate_prm(prm: &str) -> Result<(), String> {
132 let prm = prm.trim();
133
134 if prm.is_empty() {
135 return Err("PRM cannot be empty".to_string());
136 }
137
138 if !prm.chars().all(|c| c.is_ascii_digit()) {
140 return Err(format!("PRM must contain only digits: {}", prm));
141 }
142
143 let len = prm.len();
145 if len != 14 && len != 18 {
146 return Err(format!(
147 "PRM must be 14 digits (France) or 18 digits (Belgium), got {}: {}",
148 len, prm
149 ));
150 }
151
152 Ok(())
153 }
154
155 pub fn api_endpoint(&self) -> &'static str {
157 match self.provider {
158 LinkyProvider::Ores => "https://ext.prod-eu.oresnet.be/v1",
159 LinkyProvider::Enedis => "https://ext.hml.myelectricaldata.fr/v1",
160 }
161 }
162}
163
164#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
166#[serde(rename_all = "snake_case")]
167pub enum LinkyProvider {
168 Ores, Enedis, }
171
172impl std::fmt::Display for LinkyProvider {
173 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174 match self {
175 LinkyProvider::Ores => write!(f, "Ores"),
176 LinkyProvider::Enedis => write!(f, "Enedis"),
177 }
178 }
179}
180
181impl std::str::FromStr for LinkyProvider {
182 type Err = String;
183 fn from_str(s: &str) -> Result<Self, Self::Err> {
184 match s {
185 "Ores" => Ok(LinkyProvider::Ores),
186 "Enedis" => Ok(LinkyProvider::Enedis),
187 _ => Err(format!("Invalid LinkyProvider: {}", s)),
188 }
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 fn sample_building_id() -> Uuid {
197 Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()
198 }
199
200 #[test]
201 fn test_create_linky_device_success() {
202 let device = LinkyDevice::new(
203 sample_building_id(),
204 "12345678901234".to_string(), LinkyProvider::Enedis,
206 "encrypted_access_token".to_string(),
207 );
208
209 assert!(device.is_ok());
210 let d = device.unwrap();
211 assert_eq!(d.building_id, sample_building_id());
212 assert_eq!(d.prm, "12345678901234");
213 assert_eq!(d.provider, LinkyProvider::Enedis);
214 assert!(d.sync_enabled);
215 assert!(d.last_sync_at.is_none());
216 }
217
218 #[test]
219 fn test_create_linky_device_belgium() {
220 let device = LinkyDevice::new(
221 sample_building_id(),
222 "541448030003312345".to_string(), LinkyProvider::Ores,
224 "encrypted_access_token".to_string(),
225 );
226
227 assert!(device.is_ok());
228 let d = device.unwrap();
229 assert_eq!(d.prm, "541448030003312345");
230 assert_eq!(d.provider, LinkyProvider::Ores);
231 }
232
233 #[test]
234 fn test_validate_prm_empty() {
235 let device = LinkyDevice::new(
236 sample_building_id(),
237 "".to_string(),
238 LinkyProvider::Enedis,
239 "encrypted_access_token".to_string(),
240 );
241
242 assert!(device.is_err());
243 assert!(device.unwrap_err().contains("PRM cannot be empty"));
244 }
245
246 #[test]
247 fn test_validate_prm_invalid_length() {
248 let device = LinkyDevice::new(
249 sample_building_id(),
250 "12345".to_string(), LinkyProvider::Enedis,
252 "encrypted_access_token".to_string(),
253 );
254
255 assert!(device.is_err());
256 assert!(device.unwrap_err().contains("must be 14 digits"));
257 }
258
259 #[test]
260 fn test_validate_prm_non_digits() {
261 let device = LinkyDevice::new(
262 sample_building_id(),
263 "1234567890ABCD".to_string(), LinkyProvider::Enedis,
265 "encrypted_access_token".to_string(),
266 );
267
268 assert!(device.is_err());
269 assert!(device.unwrap_err().contains("must contain only digits"));
270 }
271
272 #[test]
273 fn test_validate_api_key_empty() {
274 let device = LinkyDevice::new(
275 sample_building_id(),
276 "12345678901234".to_string(),
277 LinkyProvider::Enedis,
278 "".to_string(),
279 );
280
281 assert!(device.is_err());
282 assert!(device.unwrap_err().contains("API key cannot be empty"));
283 }
284
285 #[test]
286 fn test_with_refresh_token() {
287 let expires_at = Utc::now() + chrono::Duration::hours(1);
288 let device = LinkyDevice::new(
289 sample_building_id(),
290 "12345678901234".to_string(),
291 LinkyProvider::Enedis,
292 "encrypted_access_token".to_string(),
293 )
294 .unwrap()
295 .with_refresh_token("encrypted_refresh_token".to_string(), expires_at);
296
297 assert!(device.refresh_token_encrypted.is_some());
298 assert_eq!(
299 device.refresh_token_encrypted.unwrap(),
300 "encrypted_refresh_token"
301 );
302 assert_eq!(device.token_expires_at.unwrap(), expires_at);
303 }
304
305 #[test]
306 fn test_set_sync_enabled() {
307 let mut device = LinkyDevice::new(
308 sample_building_id(),
309 "12345678901234".to_string(),
310 LinkyProvider::Enedis,
311 "encrypted_access_token".to_string(),
312 )
313 .unwrap();
314
315 assert!(device.sync_enabled);
316
317 device.set_sync_enabled(false);
318 assert!(!device.sync_enabled);
319 }
320
321 #[test]
322 fn test_mark_synced() {
323 let mut device = LinkyDevice::new(
324 sample_building_id(),
325 "12345678901234".to_string(),
326 LinkyProvider::Enedis,
327 "encrypted_access_token".to_string(),
328 )
329 .unwrap();
330
331 assert!(device.last_sync_at.is_none());
332
333 device.mark_synced();
334 assert!(device.last_sync_at.is_some());
335 assert!(device.last_sync_at.unwrap() <= Utc::now());
336 }
337
338 #[test]
339 fn test_update_tokens() {
340 let mut device = LinkyDevice::new(
341 sample_building_id(),
342 "12345678901234".to_string(),
343 LinkyProvider::Enedis,
344 "old_token".to_string(),
345 )
346 .unwrap();
347
348 let expires_at = Utc::now() + chrono::Duration::hours(2);
349 let result = device.update_tokens(
350 "new_access_token".to_string(),
351 Some("new_refresh_token".to_string()),
352 Some(expires_at),
353 );
354
355 assert!(result.is_ok());
356 assert_eq!(device.api_key_encrypted, "new_access_token");
357 assert_eq!(device.refresh_token_encrypted.unwrap(), "new_refresh_token");
358 assert_eq!(device.token_expires_at.unwrap(), expires_at);
359 }
360
361 #[test]
362 fn test_update_tokens_empty() {
363 let mut device = LinkyDevice::new(
364 sample_building_id(),
365 "12345678901234".to_string(),
366 LinkyProvider::Enedis,
367 "old_token".to_string(),
368 )
369 .unwrap();
370
371 let result = device.update_tokens("".to_string(), None, None);
372
373 assert!(result.is_err());
374 assert!(result.unwrap_err().contains("API key cannot be empty"));
375 }
376
377 #[test]
378 fn test_is_token_expired() {
379 let mut device = LinkyDevice::new(
380 sample_building_id(),
381 "12345678901234".to_string(),
382 LinkyProvider::Enedis,
383 "encrypted_access_token".to_string(),
384 )
385 .unwrap();
386
387 assert!(!device.is_token_expired());
389
390 device.token_expires_at = Some(Utc::now() + chrono::Duration::minutes(10));
392 assert!(!device.is_token_expired());
393
394 device.token_expires_at = Some(Utc::now() + chrono::Duration::minutes(3));
396 assert!(device.is_token_expired());
397
398 device.token_expires_at = Some(Utc::now() - chrono::Duration::hours(1));
400 assert!(device.is_token_expired());
401 }
402
403 #[test]
404 fn test_needs_sync() {
405 let mut device = LinkyDevice::new(
406 sample_building_id(),
407 "12345678901234".to_string(),
408 LinkyProvider::Enedis,
409 "encrypted_access_token".to_string(),
410 )
411 .unwrap();
412
413 assert!(device.needs_sync());
415
416 device.last_sync_at = Some(Utc::now() - chrono::Duration::hours(1));
418 assert!(!device.needs_sync());
419
420 device.last_sync_at = Some(Utc::now() - chrono::Duration::hours(25));
422 assert!(device.needs_sync());
423
424 device.set_sync_enabled(false);
426 assert!(!device.needs_sync());
427 }
428
429 #[test]
430 fn test_api_endpoint() {
431 let device_ores = LinkyDevice::new(
432 sample_building_id(),
433 "541448030003312345".to_string(),
434 LinkyProvider::Ores,
435 "encrypted_access_token".to_string(),
436 )
437 .unwrap();
438
439 assert_eq!(
440 device_ores.api_endpoint(),
441 "https://ext.prod-eu.oresnet.be/v1"
442 );
443
444 let device_enedis = LinkyDevice::new(
445 sample_building_id(),
446 "12345678901234".to_string(),
447 LinkyProvider::Enedis,
448 "encrypted_access_token".to_string(),
449 )
450 .unwrap();
451
452 assert_eq!(
453 device_enedis.api_endpoint(),
454 "https://ext.hml.myelectricaldata.fr/v1"
455 );
456 }
457}