1use anyhow::Result;
2use chrono::Utc;
3use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
4use log::debug;
5use serde::{Deserialize, Serialize};
6use std::sync::Arc;
7
8use crate::installation_key::{
9 generate_extra_request_headers, generate_request_proof, InstallationKey,
10};
11use crate::types::Credentials;
12
13#[derive(Serialize)]
14pub struct LoginRequest {
15 pub username: String,
16 pub password: String,
17}
18
19#[derive(Deserialize)]
20pub struct LoginResponse {
21 #[serde(rename = "accessToken")]
22 pub access_token: String,
23 #[serde(rename = "refreshToken")]
24 pub refresh_token: String,
25}
26
27#[derive(Serialize)]
28pub struct RefreshRequest {
29 #[serde(rename = "refreshToken")]
30 pub refresh_token: String,
31}
32
33#[derive(Deserialize)]
34pub struct RefreshResponse {
35 #[serde(rename = "accessToken")]
36 pub access_token: String,
37 #[serde(rename = "refreshToken")]
38 pub refresh_token: String,
39}
40
41#[derive(Deserialize)]
42pub struct ErrorResponse {
43 #[allow(dead_code)]
44 pub error: String,
45 #[allow(dead_code)]
46 pub message: Option<String>,
47}
48
49#[derive(Debug, Deserialize)]
51#[allow(dead_code)]
52struct Claims {
53 sub: String, exp: i64, iat: i64, }
57
58pub trait TokenRefreshCallback: Send + Sync {
60 fn on_tokens_refreshed(&self, credentials: &Credentials);
61}
62
63pub fn is_token_expired(token: &str, buffer_seconds: u64) -> bool {
73 if !token.starts_with("ey") {
75 return false;
77 }
78
79 let mut validation = Validation::new(Algorithm::HS512);
81 validation.insecure_disable_signature_validation();
82
83 match decode::<Claims>(
84 token,
85 &DecodingKey::from_secret(&[]), &validation,
87 ) {
88 Ok(token_data) => {
89 let now = Utc::now().timestamp() as u64;
90 let exp = token_data.claims.exp as u64;
91 now + buffer_seconds >= exp
92 }
93 Err(_) => true, }
95}
96
97pub struct AuthenticationClient {
99 client: reqwest::Client,
100 base_url: String,
101}
102
103impl Default for AuthenticationClient {
104 fn default() -> Self {
105 Self::new()
106 }
107}
108
109impl AuthenticationClient {
110 pub fn new() -> Self {
111 Self {
112 client: reqwest::Client::new(),
113 base_url: "https://lion.lamarzocco.io/api/customer-app".to_string(),
114 }
115 }
116
117 pub fn new_with_base_url(base_url: String) -> Self {
118 Self {
119 client: reqwest::Client::new(),
120 base_url,
121 }
122 }
123
124 pub async fn register_client(&self, installation_key: &InstallationKey) -> Result<()> {
126 let url = format!("{}/auth/init", self.base_url);
127
128 let base_string = installation_key.base_string();
130 let proof = generate_request_proof(&base_string, &installation_key.secret)?;
131
132 let mut headers = reqwest::header::HeaderMap::new();
134 headers.insert(
135 reqwest::header::CONTENT_TYPE,
136 reqwest::header::HeaderValue::from_static("application/json"),
137 );
138 headers.insert(
139 "X-App-Installation-Id",
140 reqwest::header::HeaderValue::from_str(&installation_key.installation_id)?,
141 );
142 headers.insert(
143 "X-Request-Proof",
144 reqwest::header::HeaderValue::from_str(&proof)?,
145 );
146
147 let body = serde_json::json!({
149 "pk": installation_key.public_key_b64()
150 });
151
152 let response = self
153 .client
154 .post(&url)
155 .headers(headers)
156 .json(&body)
157 .send()
158 .await?;
159
160 let status = response.status();
161 if status.is_success() {
162 debug!("Client registration successful");
163 Ok(())
164 } else {
165 let error_text = response.text().await?;
166 debug!("Client registration failed: {}", error_text);
167
168 if status.as_u16() == 401 {
169 return Err(anyhow::anyhow!("Registration failed: Invalid credentials"));
170 }
171
172 Err(anyhow::anyhow!("Registration failed: {}", error_text))
173 }
174 }
175
176 pub async fn login(&self, username: &str, password: &str) -> Result<Credentials> {
178 self.login_with_installation_key(username, password, None)
179 .await
180 }
181
182 pub async fn login_with_installation_key(
184 &self,
185 username: &str,
186 password: &str,
187 installation_key: Option<&InstallationKey>,
188 ) -> Result<Credentials> {
189 let login_request = LoginRequest {
190 username: username.to_string(),
191 password: password.to_string(),
192 };
193
194 let mut request = self
195 .client
196 .post(format!("{}/auth/signin", self.base_url))
197 .json(&login_request);
198
199 if let Some(key) = installation_key {
201 let extra_headers = generate_extra_request_headers(key)?;
202 let mut headers = reqwest::header::HeaderMap::new();
203 for (name, value) in extra_headers {
204 headers.insert(
205 reqwest::header::HeaderName::from_bytes(name.as_bytes())?,
206 reqwest::header::HeaderValue::from_str(&value)?,
207 );
208 }
209 request = request.headers(headers);
210 }
211
212 let response = request.send().await?;
213
214 let status = response.status();
215 let response_text = response.text().await?;
216
217 if status.is_success() {
218 match serde_json::from_str::<LoginResponse>(&response_text) {
219 Ok(login_response) => {
220 debug!("Authentication successful for user: {}", username);
221 Ok(Credentials {
222 access_token: login_response.access_token,
223 refresh_token: login_response.refresh_token,
224 username: username.to_string(),
225 installation_key: installation_key.cloned(),
226 })
227 }
228 Err(e) => {
229 debug!("Failed to parse login response: {}", e);
230 Err(anyhow::anyhow!("Failed to parse authentication response"))
231 }
232 }
233 } else {
234 debug!("Authentication failed with status: {}", status);
235 debug!("Error response: {}", response_text);
236
237 if let Ok(error_response) = serde_json::from_str::<ErrorResponse>(&response_text) {
239 if status.as_u16() == 401 {
240 return Err(anyhow::anyhow!("Invalid username or password. Please check your credentials and try again."));
241 }
242 let message = error_response.message.unwrap_or(error_response.error);
244 return Err(anyhow::anyhow!("Authentication failed: {}", message));
245 }
246
247 if status.as_u16() == 401 {
249 return Err(anyhow::anyhow!(
250 "Invalid username or password. Please check your credentials and try again."
251 ));
252 }
253
254 Err(anyhow::anyhow!(
255 "Authentication failed with status {}: {}",
256 status,
257 response_text
258 ))
259 }
260 }
261
262 pub async fn refresh_token(&self, refresh_token: &str) -> Result<Credentials> {
264 self.refresh_token_with_installation_key(refresh_token, None)
265 .await
266 }
267
268 pub async fn refresh_token_with_installation_key(
270 &self,
271 refresh_token: &str,
272 installation_key: Option<&InstallationKey>,
273 ) -> Result<Credentials> {
274 let refresh_request = RefreshRequest {
275 refresh_token: refresh_token.to_string(),
276 };
277
278 let mut request = self
279 .client
280 .post(format!("{}/auth/refreshtoken", self.base_url))
281 .json(&refresh_request);
282
283 if let Some(key) = installation_key {
285 let extra_headers = generate_extra_request_headers(key)?;
286 let mut headers = reqwest::header::HeaderMap::new();
287 for (name, value) in extra_headers {
288 headers.insert(
289 reqwest::header::HeaderName::from_bytes(name.as_bytes())?,
290 reqwest::header::HeaderValue::from_str(&value)?,
291 );
292 }
293 request = request.headers(headers);
294 }
295
296 let response = request.send().await?;
297
298 let status = response.status();
299 let response_text = response.text().await?;
300
301 if status.is_success() {
302 match serde_json::from_str::<RefreshResponse>(&response_text) {
303 Ok(refresh_response) => {
304 debug!("Token refresh successful");
305 let username = self
308 .extract_username_from_token(&refresh_response.access_token)
309 .unwrap_or_else(|| "unknown".to_string());
310
311 Ok(Credentials {
312 access_token: refresh_response.access_token,
313 refresh_token: refresh_response.refresh_token,
314 username,
315 installation_key: installation_key.cloned(),
316 })
317 }
318 Err(e) => {
319 debug!("Failed to parse refresh response: {}", e);
320 Err(anyhow::anyhow!("Failed to parse token refresh response"))
321 }
322 }
323 } else {
324 debug!("Token refresh failed with status: {}", status);
325 Err(anyhow::anyhow!("Token refresh failed: {}", response_text))
326 }
327 }
328
329 fn extract_username_from_token(&self, token: &str) -> Option<String> {
331 if !token.starts_with("ey") {
333 return None;
334 }
335
336 let mut validation = Validation::new(Algorithm::HS512);
338 validation.insecure_disable_signature_validation();
339
340 match decode::<Claims>(
341 token,
342 &DecodingKey::from_secret(&[]), &validation,
344 ) {
345 Ok(token_data) => Some(token_data.claims.sub),
346 Err(_) => None,
347 }
348 }
349}
350
351pub struct ApiClient {
353 client: reqwest::Client,
354 base_url: String,
355 credentials: Credentials,
356 refresh_callback: Option<Arc<dyn TokenRefreshCallback>>,
357 auth_client: AuthenticationClient,
358}
359
360impl ApiClient {
361 pub fn new(
362 tokens: Credentials,
363 refresh_callback: Option<Arc<dyn TokenRefreshCallback>>,
364 ) -> Self {
365 Self {
366 client: reqwest::Client::new(),
367 base_url: "https://lion.lamarzocco.io/api/customer-app".to_string(),
368 credentials: tokens,
369 refresh_callback,
370 auth_client: AuthenticationClient::new(),
371 }
372 }
373
374 pub fn new_with_base_url(
375 tokens: Credentials,
376 refresh_callback: Option<Arc<dyn TokenRefreshCallback>>,
377 base_url: String,
378 ) -> Self {
379 Self {
380 client: reqwest::Client::new(),
381 base_url: base_url.clone(),
382 credentials: tokens,
383 refresh_callback,
384 auth_client: AuthenticationClient::new_with_base_url(base_url),
385 }
386 }
387
388 async fn ensure_valid_token(&mut self) -> Result<()> {
390 if is_token_expired(&self.credentials.access_token, 300) {
392 debug!("Access token expired, attempting refresh");
393
394 match self
396 .auth_client
397 .refresh_token_with_installation_key(
398 &self.credentials.refresh_token,
399 self.credentials.installation_key.as_ref(),
400 )
401 .await
402 {
403 Ok(new_tokens) => {
404 debug!("Token refresh successful");
405 self.credentials = new_tokens;
406
407 if let Some(callback) = &self.refresh_callback {
409 callback.on_tokens_refreshed(&self.credentials);
410 }
411
412 return Ok(());
413 }
414 Err(e) => {
415 debug!("Token refresh failed: {}", e);
416 return Err(anyhow::anyhow!(
417 "Access token expired and token refresh failed: {}. Please re-authenticate.",
418 e
419 ));
420 }
421 }
422 }
423 Ok(())
424 }
425
426 async fn get_headers(&mut self) -> Result<reqwest::header::HeaderMap> {
428 self.ensure_valid_token().await?;
429
430 let mut headers = reqwest::header::HeaderMap::new();
431 headers.insert(
432 reqwest::header::CONTENT_TYPE,
433 reqwest::header::HeaderValue::from_static("application/json"),
434 );
435
436 let auth_value = format!("Bearer {}", self.credentials.access_token);
437 headers.insert(
438 reqwest::header::AUTHORIZATION,
439 reqwest::header::HeaderValue::from_str(&auth_value)?,
440 );
441
442 if let Some(installation_key) = &self.credentials.installation_key {
444 let extra_headers = generate_extra_request_headers(installation_key)?;
445 for (name, value) in extra_headers {
446 headers.insert(
447 reqwest::header::HeaderName::from_bytes(name.as_bytes())?,
448 reqwest::header::HeaderValue::from_str(&value)?,
449 );
450 }
451 }
452
453 Ok(headers)
454 }
455
456 pub async fn get_machines(&mut self) -> Result<Vec<crate::types::Machine>> {
458 let url = format!("{}/things", self.base_url);
459 let headers = self.get_headers().await?;
460
461 let response = self.client.get(&url).headers(headers).send().await?;
462
463 let status = response.status();
464 if status.is_success() {
465 let response_text = response.text().await?;
466
467 match serde_json::from_str::<Vec<crate::types::Machine>>(&response_text) {
469 Ok(machines) => {
470 debug!("Found {} machines", machines.len());
471 Ok(machines)
472 }
473 Err(_) => {
474 match serde_json::from_str::<crate::types::MachinesResponse>(&response_text) {
476 Ok(machines_response) => {
477 debug!(
478 "Found {} machines (wrapped in 'things')",
479 machines_response.things.len()
480 );
481 Ok(machines_response.things)
482 }
483 Err(e) => {
484 debug!("Failed to parse machines response: {}", e);
485 Err(anyhow::anyhow!("Failed to parse machines response: {}", e))
486 }
487 }
488 }
489 }
490 } else {
491 let error_text = response.text().await?;
492 debug!("Failed to fetch machines: {}", error_text);
493
494 if status.as_u16() == 401 {
496 return Err(anyhow::anyhow!(
497 "Authentication failed. Please run 'lm login' again."
498 ));
499 }
500
501 Err(anyhow::anyhow!("Failed to fetch machines: {}", error_text))
502 }
503 }
504
505 pub async fn get_machine_status(
507 &mut self,
508 serial_number: &str,
509 ) -> Result<crate::types::MachineStatus> {
510 let url = format!("{}/things/{}/dashboard", self.base_url, serial_number);
511 let headers = self.get_headers().await?;
512
513 let response = self.client.get(&url).headers(headers).send().await?;
514
515 let status = response.status();
516 if status.is_success() {
517 let response_text = response.text().await?;
518
519 match serde_json::from_str::<crate::types::MachineStatus>(&response_text) {
520 Ok(status) => {
521 debug!("Machine {} status: on={}", serial_number, status.is_on());
522 Ok(status)
523 }
524 Err(e) => {
525 debug!("Failed to parse machine status: {}", e);
526 debug!("Raw response: {}", response_text);
527 Err(anyhow::anyhow!("Failed to parse machine status: {}", e))
528 }
529 }
530 } else {
531 let error_text = response.text().await?;
532 debug!("Failed to fetch machine status: {}", error_text);
533
534 if status.as_u16() == 401 {
536 return Err(anyhow::anyhow!(
537 "Authentication failed. Please run 'lm login' again."
538 ));
539 }
540
541 Err(anyhow::anyhow!(
542 "Failed to fetch machine status: {}",
543 error_text
544 ))
545 }
546 }
547
548 pub async fn turn_on_machine(&mut self, serial_number: &str) -> Result<()> {
550 self.send_machine_command(serial_number, crate::types::MachineCommand::turn_on())
551 .await
552 }
553
554 pub async fn turn_off_machine(&mut self, serial_number: &str) -> Result<()> {
556 self.send_machine_command(serial_number, crate::types::MachineCommand::turn_off())
557 .await
558 }
559
560 async fn send_machine_command(
562 &mut self,
563 serial_number: &str,
564 command: crate::types::MachineCommand,
565 ) -> Result<()> {
566 let url = format!(
567 "{}/things/{}/command/CoffeeMachineChangeMode",
568 self.base_url, serial_number
569 );
570 let headers = self.get_headers().await?;
571
572 debug!("Sending command to {}: {:?}", serial_number, command);
573
574 let response = self
575 .client
576 .post(&url)
577 .headers(headers)
578 .json(&command)
579 .send()
580 .await?;
581
582 if response.status().is_success() {
583 debug!("Command sent successfully to machine: {}", serial_number);
584 Ok(())
585 } else {
586 let status = response.status();
587 let error_text = response.text().await?;
588 debug!("Failed to send command to machine: {}", error_text);
589
590 if status.as_u16() == 401 {
592 return Err(anyhow::anyhow!(
593 "Authentication failed. Please run 'lm login' again."
594 ));
595 }
596
597 Err(anyhow::anyhow!(
598 "Failed to send command to machine: {}",
599 error_text
600 ))
601 }
602 }
603}
604
605pub async fn authenticate_with_url(
606 client: &reqwest::Client,
607 base_url: &str,
608 username: &str,
609 password: &str,
610) -> Result<String> {
611 let login_request = LoginRequest {
612 username: username.to_string(),
613 password: password.to_string(),
614 };
615
616 let response = client
617 .post(format!("{}/auth/signin", base_url))
618 .json(&login_request)
619 .send()
620 .await?;
621
622 let status = response.status();
623 let response_text = response.text().await?;
624
625 if status.is_success() {
626 match serde_json::from_str::<LoginResponse>(&response_text) {
627 Ok(login_response) => {
628 debug!("Authentication successful");
629 Ok(login_response.access_token)
630 }
631 Err(e) => {
632 debug!("Failed to parse login response: {}", e);
633 Err(anyhow::anyhow!("Failed to parse authentication response"))
634 }
635 }
636 } else {
637 debug!("Authentication failed with status: {}", status);
638 Err(anyhow::anyhow!("Authentication failed: {}", response_text))
639 }
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645
646 #[test]
647 fn test_auth_response_parsing() {
648 let json = r#"{"accessToken":"eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtZUB0aW1yb2dlcnMuY28udWsiLCJpYXQiOjE3NDg1MzMwNDgsImV4cCI6MTc4MDA2OTA0OH0.fQJam2zsJopWMKtti0gOJ_1uUfyFop5tixsnlMWu-qhQeg0vb6BG8nTdRRx2Hw_ORxGLPrN4xyJatzpKPJ5YDA","refreshToken":"foo"}"#;
650
651 let auth_response: LoginResponse = serde_json::from_str(json).unwrap();
652 assert_eq!(
653 auth_response.access_token,
654 "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtZUB0aW1yb2dlcnMuY28udWsiLCJpYXQiOjE3NDg1MzMwNDgsImV4cCI6MTc4MDA2OTA0OH0.fQJam2zsJopWMKtti0gOJ_1uUfyFop5tixsnlMWu-qhQeg0vb6BG8nTdRRx2Hw_ORxGLPrN4xyJatzpKPJ5YDA"
655 );
656 assert_eq!(auth_response.refresh_token, "foo");
657 }
658
659 #[test]
660 fn test_refresh_request_serialization() {
661 let request = RefreshRequest {
662 refresh_token: "refresh_token_123".to_string(),
663 };
664
665 let json = serde_json::to_string(&request).unwrap();
666 assert!(json.contains("refresh_token_123"));
667 assert!(json.contains("refreshToken"));
668 }
669
670 #[test]
671 fn test_refresh_response_parsing() {
672 let json = r#"{"accessToken":"new_access_token","refreshToken":"new_refresh_token"}"#;
673
674 let refresh_response: RefreshResponse = serde_json::from_str(json).unwrap();
675 assert_eq!(refresh_response.access_token, "new_access_token");
676 assert_eq!(
677 refresh_response.refresh_token,
678 "new_refresh_token".to_string()
679 );
680 }
681
682 #[test]
683 fn test_auth_response_with_refresh_token() {
684 let json = r#"{"accessToken":"access123","refreshToken":"refresh456"}"#;
686
687 let auth_response: LoginResponse = serde_json::from_str(json).unwrap();
688 assert_eq!(auth_response.access_token, "access123");
689 assert_eq!(auth_response.refresh_token, "refresh456".to_string());
690 }
691
692 #[test]
693 fn test_login_request_serialization() {
694 let request = LoginRequest {
695 username: "test@example.com".to_string(),
696 password: "password123".to_string(),
697 };
698
699 let json = serde_json::to_string(&request).unwrap();
700 assert!(json.contains("test@example.com"));
701 assert!(json.contains("password123"));
702 }
703
704 #[test]
705 fn test_token_expiration_check() {
706 let future_exp = (Utc::now().timestamp() + 3600) as u64; let _token = format!(
709 "eyJhbGciOiJIUzUxMiJ9.{{\"sub\":\"test@example.com\",\"exp\":{},\"iat\":1234567890}}.signature",
710 future_exp
711 );
712
713 let invalid_jwt_token = "eyJhbGciOiJIUzUxMiJ9.invalid.token";
718 assert!(is_token_expired(invalid_jwt_token, 0));
719
720 let test_token = "simple_test_token";
722 assert!(!is_token_expired(test_token, 0));
723
724 let fixture_token = "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtZUB0aW1yb2dlcnMuY28udWsiLCJpYXQiOjE3NDg1MzMwNDgsImV4cCI6MTc4MDA2OTA0OH0.fQJam2zsJopWMKtti0gOJ_1uUfyFop5tixsnlMWu-qhQeg0vb6BG8nTdRRx2Hw_ORxGLPrN4xyJatzpKPJ5YDA";
726 assert!(
727 !is_token_expired(fixture_token, 0),
728 "Fixture token should not be expired"
729 );
730 assert!(
731 !is_token_expired(fixture_token, 300),
732 "Fixture token should not be expired even with 5-minute buffer"
733 );
734 }
735
736 #[test]
737 fn test_auth_tokens_creation() {
738 let tokens = Credentials {
739 access_token: "access123".to_string(),
740 refresh_token: "refresh456".to_string(),
741 username: "test@example.com".to_string(),
742 installation_key: None,
743 };
744
745 assert_eq!(tokens.access_token, "access123");
746 assert_eq!(tokens.refresh_token, "refresh456".to_string());
747 assert_eq!(tokens.username, "test@example.com");
748 }
749
750 #[test]
751 fn test_authentication_client_creation() {
752 let auth_client = AuthenticationClient::new();
753 assert_eq!(
754 auth_client.base_url,
755 "https://lion.lamarzocco.io/api/customer-app"
756 );
757
758 let custom_url = "https://test.example.com".to_string();
759 let auth_client_custom = AuthenticationClient::new_with_base_url(custom_url.clone());
760 assert_eq!(auth_client_custom.base_url, custom_url);
761 }
762
763 #[test]
764 fn test_api_client_creation() {
765 let tokens = Credentials {
766 access_token: "access123".to_string(),
767 refresh_token: "refresh456".to_string(),
768 username: "test@example.com".to_string(),
769 installation_key: None,
770 };
771
772 let api_client = ApiClient::new(tokens.clone(), None);
773 assert_eq!(
774 api_client.base_url,
775 "https://lion.lamarzocco.io/api/customer-app"
776 );
777 assert_eq!(api_client.credentials.access_token, "access123");
778
779 let custom_url = "https://test.example.com".to_string();
780 let api_client_custom = ApiClient::new_with_base_url(tokens, None, custom_url.clone());
781 assert_eq!(api_client_custom.base_url, custom_url);
782 }
783
784 #[test]
785 fn test_username_extraction_from_jwt() {
786 let auth_client = AuthenticationClient::new();
787
788 let jwt_token = "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiaWF0IjoxNzQ4NTMzMDQ4LCJleHAiOjE3ODAwNjkwNDh9.fQJam2zsJopWMKtti0gOJ_1uUfyFop5tixsnlMWu-qhQeg0vb6BG8nTdRRx2Hw_ORxGLPrN4xyJatzpKPJ5YDA";
790 let username = auth_client.extract_username_from_token(jwt_token);
791 assert_eq!(username, Some("test@example.com".to_string()));
792
793 let simple_token = "simple_test_token";
795 let username = auth_client.extract_username_from_token(simple_token);
796 assert_eq!(username, None);
797
798 let invalid_jwt = "eyJhbGciOiJIUzUxMiJ9.invalid.token";
800 let username = auth_client.extract_username_from_token(invalid_jwt);
801 assert_eq!(username, None);
802 }
803
804 #[test]
805 fn test_login_with_installation_key() {
806 use crate::installation_key::generate_installation_key;
807
808 let auth_client = AuthenticationClient::new();
810 let installation_key =
811 generate_installation_key("test-installation-id".to_string()).unwrap();
812
813 let _future = auth_client.login_with_installation_key(
816 "test@example.com",
817 "password",
818 Some(&installation_key),
819 );
820 }
821
822 #[test]
823 fn test_api_client_with_installation_key() {
824 use crate::installation_key::generate_installation_key;
825
826 let installation_key =
827 generate_installation_key("test-installation-id".to_string()).unwrap();
828 let tokens = Credentials {
829 access_token: "access123".to_string(),
830 refresh_token: "refresh456".to_string(),
831 username: "test@example.com".to_string(),
832 installation_key: Some(installation_key),
833 };
834
835 let api_client = ApiClient::new(tokens.clone(), None);
836 assert_eq!(
837 api_client.base_url,
838 "https://lion.lamarzocco.io/api/customer-app"
839 );
840 assert_eq!(api_client.credentials.access_token, "access123");
841 assert!(api_client.credentials.installation_key.is_some());
842 }
843}