1use crate::auth::associated_user::AssociatedUser;
50use crate::auth::AuthScopes;
51use crate::config::ShopDomain;
52use chrono::{DateTime, Duration, Utc};
53use serde::{Deserialize, Serialize};
54
55const REFRESH_TOKEN_EXPIRY_BUFFER_SECONDS: i64 = 60;
58
59#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
110pub struct Session {
111 pub id: String,
116
117 pub shop: ShopDomain,
119
120 pub access_token: String,
122
123 pub scopes: AuthScopes,
125
126 pub is_online: bool,
128
129 pub expires: Option<DateTime<Utc>>,
134
135 pub state: Option<String>,
139
140 pub shopify_session_id: Option<String>,
142
143 pub associated_user: Option<AssociatedUser>,
147
148 pub associated_user_scopes: Option<AuthScopes>,
153
154 #[serde(default)]
161 pub refresh_token: Option<String>,
162
163 #[serde(default)]
169 pub refresh_token_expires_at: Option<DateTime<Utc>>,
170}
171
172impl Session {
173 #[must_use]
205 pub const fn new(
206 id: String,
207 shop: ShopDomain,
208 access_token: String,
209 scopes: AuthScopes,
210 is_online: bool,
211 expires: Option<DateTime<Utc>>,
212 ) -> Self {
213 Self {
214 id,
215 shop,
216 access_token,
217 scopes,
218 is_online,
219 expires,
220 state: None,
221 shopify_session_id: None,
222 associated_user: None,
223 associated_user_scopes: None,
224 refresh_token: None,
225 refresh_token_expires_at: None,
226 }
227 }
228
229 #[must_use]
273 #[allow(clippy::too_many_arguments)]
274 pub const fn with_user(
275 id: String,
276 shop: ShopDomain,
277 access_token: String,
278 scopes: AuthScopes,
279 expires: Option<DateTime<Utc>>,
280 associated_user: AssociatedUser,
281 associated_user_scopes: Option<AuthScopes>,
282 ) -> Self {
283 Self {
284 id,
285 shop,
286 access_token,
287 scopes,
288 is_online: true,
289 expires,
290 state: None,
291 shopify_session_id: None,
292 associated_user: Some(associated_user),
293 associated_user_scopes,
294 refresh_token: None,
295 refresh_token_expires_at: None,
296 }
297 }
298
299 #[must_use]
313 pub fn generate_offline_id(shop: &ShopDomain) -> String {
314 format!("offline_{}", shop.as_ref())
315 }
316
317 #[must_use]
332 pub fn generate_online_id(shop: &ShopDomain, user_id: u64) -> String {
333 format!("{}_{}", shop.as_ref(), user_id)
334 }
335
336 #[must_use]
373 pub fn from_access_token_response(shop: ShopDomain, response: &AccessTokenResponse) -> Self {
374 let is_online = response.associated_user.is_some();
375
376 let id = response.associated_user.as_ref().map_or_else(
377 || Self::generate_offline_id(&shop),
378 |user| Self::generate_online_id(&shop, user.id),
379 );
380
381 let scopes: AuthScopes = response.scope.parse().unwrap_or_default();
382
383 let expires = response
384 .expires_in
385 .map(|secs| Utc::now() + Duration::seconds(i64::from(secs)));
386
387 let associated_user_scopes = response
388 .associated_user_scope
389 .as_ref()
390 .and_then(|s| s.parse().ok());
391
392 let associated_user = response.associated_user.as_ref().map(|u| AssociatedUser {
393 id: u.id,
394 first_name: u.first_name.clone(),
395 last_name: u.last_name.clone(),
396 email: u.email.clone(),
397 email_verified: u.email_verified,
398 account_owner: u.account_owner,
399 locale: u.locale.clone(),
400 collaborator: u.collaborator,
401 });
402
403 let refresh_token = response.refresh_token.clone();
404
405 let refresh_token_expires_at = response
406 .refresh_token_expires_in
407 .map(|secs| Utc::now() + Duration::seconds(i64::from(secs)));
408
409 Self {
410 id,
411 shop,
412 access_token: response.access_token.clone(),
413 scopes,
414 is_online,
415 expires,
416 state: None,
417 shopify_session_id: response.session.clone(),
418 associated_user,
419 associated_user_scopes,
420 refresh_token,
421 refresh_token_expires_at,
422 }
423 }
424
425 #[must_use]
429 pub fn expired(&self) -> bool {
430 self.expires.is_some_and(|expires| Utc::now() > expires)
431 }
432
433 #[must_use]
435 pub fn is_active(&self) -> bool {
436 !self.access_token.is_empty() && !self.expired()
437 }
438
439 #[must_use]
466 pub fn refresh_token_expired(&self) -> bool {
467 self.refresh_token_expires_at.is_some_and(|expires_at| {
468 let buffer = Duration::seconds(REFRESH_TOKEN_EXPIRY_BUFFER_SECONDS);
469 Utc::now() + buffer > expires_at
470 })
471 }
472}
473
474#[derive(Clone, Debug, Deserialize)]
482pub struct AccessTokenResponse {
483 pub access_token: String,
485
486 pub scope: String,
488
489 pub expires_in: Option<u32>,
491
492 pub associated_user_scope: Option<String>,
494
495 pub associated_user: Option<AssociatedUserResponse>,
497
498 #[serde(rename = "session")]
500 pub session: Option<String>,
501
502 pub refresh_token: Option<String>,
506
507 pub refresh_token_expires_in: Option<u32>,
511}
512
513#[derive(Clone, Debug, Deserialize)]
517pub struct AssociatedUserResponse {
518 pub id: u64,
520
521 pub first_name: String,
523
524 pub last_name: String,
526
527 pub email: String,
529
530 pub email_verified: bool,
532
533 pub account_owner: bool,
535
536 pub locale: String,
538
539 pub collaborator: bool,
541}
542
543const _: fn() = || {
545 const fn assert_send_sync<T: Send + Sync>() {}
546 assert_send_sync::<Session>();
547};
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552
553 fn sample_shop() -> ShopDomain {
554 ShopDomain::new("my-store").unwrap()
555 }
556
557 fn sample_scopes() -> AuthScopes {
558 "read_products,write_orders".parse().unwrap()
559 }
560
561 fn sample_user() -> AssociatedUser {
562 AssociatedUser::new(
563 12345,
564 "Jane".to_string(),
565 "Doe".to_string(),
566 "jane@example.com".to_string(),
567 true,
568 true,
569 "en".to_string(),
570 false,
571 )
572 }
573
574 #[test]
577 fn test_session_expired() {
578 let expired = Session::new(
580 "id".to_string(),
581 ShopDomain::new("shop").unwrap(),
582 "token".to_string(),
583 AuthScopes::new(),
584 false,
585 Some(Utc::now() - Duration::hours(1)),
586 );
587 assert!(expired.expired());
588
589 let valid = Session::new(
591 "id".to_string(),
592 ShopDomain::new("shop").unwrap(),
593 "token".to_string(),
594 AuthScopes::new(),
595 false,
596 Some(Utc::now() + Duration::hours(1)),
597 );
598 assert!(!valid.expired());
599
600 let no_expiry = Session::new(
602 "id".to_string(),
603 ShopDomain::new("shop").unwrap(),
604 "token".to_string(),
605 AuthScopes::new(),
606 false,
607 None,
608 );
609 assert!(!no_expiry.expired());
610 }
611
612 #[test]
613 fn test_session_is_active() {
614 let active = Session::new(
616 "id".to_string(),
617 ShopDomain::new("shop").unwrap(),
618 "token".to_string(),
619 AuthScopes::new(),
620 false,
621 None,
622 );
623 assert!(active.is_active());
624
625 let no_token = Session::new(
627 "id".to_string(),
628 ShopDomain::new("shop").unwrap(),
629 String::new(),
630 AuthScopes::new(),
631 false,
632 None,
633 );
634 assert!(!no_token.is_active());
635
636 let expired = Session::new(
638 "id".to_string(),
639 ShopDomain::new("shop").unwrap(),
640 "token".to_string(),
641 AuthScopes::new(),
642 false,
643 Some(Utc::now() - Duration::hours(1)),
644 );
645 assert!(!expired.is_active());
646 }
647
648 #[test]
649 fn test_session_is_send_sync() {
650 fn assert_send_sync<T: Send + Sync>() {}
651 assert_send_sync::<Session>();
652 }
653
654 #[test]
657 fn test_session_with_associated_user_field() {
658 let user = sample_user();
659 let session = Session::with_user(
660 "test-session".to_string(),
661 sample_shop(),
662 "access-token".to_string(),
663 sample_scopes(),
664 Some(Utc::now() + Duration::hours(1)),
665 user.clone(),
666 None,
667 );
668
669 assert!(session.associated_user.is_some());
670 let stored_user = session.associated_user.unwrap();
671 assert_eq!(stored_user.id, 12345);
672 assert_eq!(stored_user.first_name, "Jane");
673 assert_eq!(stored_user.email, "jane@example.com");
674 }
675
676 #[test]
677 fn test_session_with_associated_user_scopes_field() {
678 let user = sample_user();
679 let user_scopes: AuthScopes = "read_products".parse().unwrap();
680
681 let session = Session::with_user(
682 "test-session".to_string(),
683 sample_shop(),
684 "access-token".to_string(),
685 sample_scopes(),
686 None,
687 user,
688 Some(user_scopes.clone()),
689 );
690
691 assert!(session.associated_user_scopes.is_some());
692 let stored_scopes = session.associated_user_scopes.unwrap();
693 assert!(stored_scopes.iter().any(|s| s == "read_products"));
694 }
695
696 #[test]
697 fn test_session_serialization_to_json() {
698 let session = Session::new(
699 "offline_my-store.myshopify.com".to_string(),
700 sample_shop(),
701 "access-token".to_string(),
702 sample_scopes(),
703 false,
704 None,
705 );
706
707 let json = serde_json::to_string(&session).unwrap();
708 assert!(json.contains("offline_my-store.myshopify.com"));
709 assert!(json.contains("access-token"));
710 assert!(json.contains("my-store.myshopify.com"));
711 }
712
713 #[test]
714 fn test_session_deserialization_from_json() {
715 let json = r#"{
716 "id": "test-session",
717 "shop": "test-shop.myshopify.com",
718 "access_token": "token123",
719 "scopes": "read_products",
720 "is_online": false,
721 "expires": null,
722 "state": null,
723 "shopify_session_id": null,
724 "associated_user": null,
725 "associated_user_scopes": null
726 }"#;
727
728 let session: Session = serde_json::from_str(json).unwrap();
729 assert_eq!(session.id, "test-session");
730 assert_eq!(session.access_token, "token123");
731 assert!(!session.is_online);
732 assert!(session.associated_user.is_none());
733 assert!(session.refresh_token.is_none());
735 assert!(session.refresh_token_expires_at.is_none());
736 }
737
738 #[test]
739 fn test_session_equality_comparison() {
740 let session1 = Session::new(
741 "id".to_string(),
742 sample_shop(),
743 "token".to_string(),
744 sample_scopes(),
745 false,
746 None,
747 );
748
749 let session2 = Session::new(
750 "id".to_string(),
751 sample_shop(),
752 "token".to_string(),
753 sample_scopes(),
754 false,
755 None,
756 );
757
758 assert_eq!(session1, session2);
759
760 let session3 = Session::new(
762 "different-id".to_string(),
763 sample_shop(),
764 "token".to_string(),
765 sample_scopes(),
766 false,
767 None,
768 );
769
770 assert_ne!(session1, session3);
771 }
772
773 #[test]
774 fn test_session_clone_preserves_all_fields() {
775 let user = sample_user();
776 let session = Session::with_user(
777 "test-id".to_string(),
778 sample_shop(),
779 "token".to_string(),
780 sample_scopes(),
781 Some(Utc::now() + Duration::hours(1)),
782 user,
783 Some("read_products".parse().unwrap()),
784 );
785
786 let cloned = session.clone();
787
788 assert_eq!(session.id, cloned.id);
789 assert_eq!(session.shop, cloned.shop);
790 assert_eq!(session.access_token, cloned.access_token);
791 assert_eq!(session.scopes, cloned.scopes);
792 assert_eq!(session.is_online, cloned.is_online);
793 assert_eq!(session.expires, cloned.expires);
794 assert_eq!(session.associated_user, cloned.associated_user);
795 assert_eq!(
796 session.associated_user_scopes,
797 cloned.associated_user_scopes
798 );
799 }
800
801 #[test]
804 fn test_generate_offline_id_produces_correct_format() {
805 let shop = ShopDomain::new("my-store").unwrap();
806 let id = Session::generate_offline_id(&shop);
807 assert_eq!(id, "offline_my-store.myshopify.com");
808 }
809
810 #[test]
811 fn test_generate_online_id_produces_correct_format() {
812 let shop = ShopDomain::new("my-store").unwrap();
813 let id = Session::generate_online_id(&shop, 12345);
814 assert_eq!(id, "my-store.myshopify.com_12345");
815 }
816
817 #[test]
818 fn test_from_access_token_response_with_offline_response() {
819 let shop = ShopDomain::new("my-store").unwrap();
820 let response = AccessTokenResponse {
821 access_token: "offline-token".to_string(),
822 scope: "read_products,write_orders".to_string(),
823 expires_in: None,
824 associated_user_scope: None,
825 associated_user: None,
826 session: None,
827 refresh_token: None,
828 refresh_token_expires_in: None,
829 };
830
831 let session = Session::from_access_token_response(shop, &response);
832
833 assert!(!session.is_online);
834 assert_eq!(session.id, "offline_my-store.myshopify.com");
835 assert_eq!(session.access_token, "offline-token");
836 assert!(session.associated_user.is_none());
837 assert!(session.expires.is_none());
838 }
839
840 #[test]
841 fn test_from_access_token_response_with_online_response() {
842 let shop = ShopDomain::new("my-store").unwrap();
843 let response = AccessTokenResponse {
844 access_token: "online-token".to_string(),
845 scope: "read_products".to_string(),
846 expires_in: Some(3600),
847 associated_user_scope: Some("read_products".to_string()),
848 associated_user: Some(AssociatedUserResponse {
849 id: 12345,
850 first_name: "Jane".to_string(),
851 last_name: "Doe".to_string(),
852 email: "jane@example.com".to_string(),
853 email_verified: true,
854 account_owner: true,
855 locale: "en".to_string(),
856 collaborator: false,
857 }),
858 session: Some("shopify-session-id".to_string()),
859 refresh_token: None,
860 refresh_token_expires_in: None,
861 };
862
863 let session = Session::from_access_token_response(shop, &response);
864
865 assert!(session.is_online);
866 assert_eq!(session.id, "my-store.myshopify.com_12345");
867 assert_eq!(session.access_token, "online-token");
868 assert!(session.associated_user.is_some());
869 assert!(session.expires.is_some());
870 assert_eq!(
871 session.shopify_session_id,
872 Some("shopify-session-id".to_string())
873 );
874
875 let user = session.associated_user.unwrap();
876 assert_eq!(user.id, 12345);
877 assert_eq!(user.email, "jane@example.com");
878 }
879
880 #[test]
881 fn test_from_access_token_response_calculates_expires() {
882 let shop = ShopDomain::new("my-store").unwrap();
883 let response = AccessTokenResponse {
884 access_token: "token".to_string(),
885 scope: "read_products".to_string(),
886 expires_in: Some(3600), associated_user_scope: None,
888 associated_user: Some(AssociatedUserResponse {
889 id: 1,
890 first_name: "Test".to_string(),
891 last_name: "User".to_string(),
892 email: "test@example.com".to_string(),
893 email_verified: true,
894 account_owner: false,
895 locale: "en".to_string(),
896 collaborator: false,
897 }),
898 session: None,
899 refresh_token: None,
900 refresh_token_expires_in: None,
901 };
902
903 let before = Utc::now();
904 let session = Session::from_access_token_response(shop, &response);
905 let after = Utc::now();
906
907 assert!(session.expires.is_some());
908 let expires = session.expires.unwrap();
909
910 let expected_min = before + Duration::seconds(3600);
912 let expected_max = after + Duration::seconds(3600);
913
914 assert!(expires >= expected_min && expires <= expected_max);
915 }
916
917 #[test]
918 fn test_from_access_token_response_parses_scopes() {
919 let shop = ShopDomain::new("my-store").unwrap();
920 let response = AccessTokenResponse {
921 access_token: "token".to_string(),
922 scope: "read_products,write_orders".to_string(),
923 expires_in: None,
924 associated_user_scope: None,
925 associated_user: None,
926 session: None,
927 refresh_token: None,
928 refresh_token_expires_in: None,
929 };
930
931 let session = Session::from_access_token_response(shop, &response);
932
933 assert!(session.scopes.iter().any(|s| s == "read_products"));
934 assert!(session.scopes.iter().any(|s| s == "write_orders"));
935 assert!(session.scopes.iter().any(|s| s == "read_orders"));
937 }
938
939 #[test]
940 fn test_from_access_token_response_sets_is_online_correctly() {
941 let shop = ShopDomain::new("my-store").unwrap();
942
943 let offline_response = AccessTokenResponse {
945 access_token: "token".to_string(),
946 scope: "read_products".to_string(),
947 expires_in: None,
948 associated_user_scope: None,
949 associated_user: None,
950 session: None,
951 refresh_token: None,
952 refresh_token_expires_in: None,
953 };
954 let offline_session = Session::from_access_token_response(shop.clone(), &offline_response);
955 assert!(!offline_session.is_online);
956
957 let online_response = AccessTokenResponse {
959 access_token: "token".to_string(),
960 scope: "read_products".to_string(),
961 expires_in: Some(3600),
962 associated_user_scope: None,
963 associated_user: Some(AssociatedUserResponse {
964 id: 1,
965 first_name: "Test".to_string(),
966 last_name: "User".to_string(),
967 email: "test@example.com".to_string(),
968 email_verified: true,
969 account_owner: false,
970 locale: "en".to_string(),
971 collaborator: false,
972 }),
973 session: None,
974 refresh_token: None,
975 refresh_token_expires_in: None,
976 };
977 let online_session = Session::from_access_token_response(shop, &online_response);
978 assert!(online_session.is_online);
979 }
980
981 #[test]
984 fn test_session_serialization_includes_refresh_token_field() {
985 let mut session = Session::new(
986 "offline_my-store.myshopify.com".to_string(),
987 sample_shop(),
988 "access-token".to_string(),
989 sample_scopes(),
990 false,
991 None,
992 );
993 session.refresh_token = Some("refresh-token-123".to_string());
994
995 let json = serde_json::to_string(&session).unwrap();
996 assert!(json.contains("refresh_token"));
997 assert!(json.contains("refresh-token-123"));
998 }
999
1000 #[test]
1001 fn test_session_serialization_includes_refresh_token_expires_at_field() {
1002 let mut session = Session::new(
1003 "offline_my-store.myshopify.com".to_string(),
1004 sample_shop(),
1005 "access-token".to_string(),
1006 sample_scopes(),
1007 false,
1008 None,
1009 );
1010 session.refresh_token_expires_at = Some(Utc::now() + Duration::days(30));
1011
1012 let json = serde_json::to_string(&session).unwrap();
1013 assert!(json.contains("refresh_token_expires_at"));
1014 }
1015
1016 #[test]
1017 fn test_session_deserialization_handles_missing_refresh_token_fields_backward_compat() {
1018 let json = r#"{
1020 "id": "test-session",
1021 "shop": "test-shop.myshopify.com",
1022 "access_token": "token123",
1023 "scopes": "read_products",
1024 "is_online": false,
1025 "expires": null,
1026 "state": null,
1027 "shopify_session_id": null,
1028 "associated_user": null,
1029 "associated_user_scopes": null
1030 }"#;
1031
1032 let session: Session = serde_json::from_str(json).unwrap();
1033 assert!(session.refresh_token.is_none());
1034 assert!(session.refresh_token_expires_at.is_none());
1035 }
1036
1037 #[test]
1038 fn test_refresh_token_expired_returns_false_when_expires_at_is_none() {
1039 let session = Session::new(
1040 "id".to_string(),
1041 sample_shop(),
1042 "token".to_string(),
1043 sample_scopes(),
1044 false,
1045 None,
1046 );
1047 assert!(!session.refresh_token_expired());
1048 }
1049
1050 #[test]
1051 fn test_refresh_token_expired_returns_false_when_expires_at_is_in_future_more_than_60s() {
1052 let mut session = Session::new(
1053 "id".to_string(),
1054 sample_shop(),
1055 "token".to_string(),
1056 sample_scopes(),
1057 false,
1058 None,
1059 );
1060 session.refresh_token_expires_at = Some(Utc::now() + Duration::hours(2));
1062
1063 assert!(!session.refresh_token_expired());
1064 }
1065
1066 #[test]
1067 fn test_refresh_token_expired_returns_true_when_expires_at_is_within_60_seconds() {
1068 let mut session = Session::new(
1069 "id".to_string(),
1070 sample_shop(),
1071 "token".to_string(),
1072 sample_scopes(),
1073 false,
1074 None,
1075 );
1076 session.refresh_token_expires_at = Some(Utc::now() + Duration::seconds(30));
1078
1079 assert!(session.refresh_token_expired());
1080 }
1081
1082 #[test]
1083 fn test_refresh_token_expired_returns_true_when_already_expired() {
1084 let mut session = Session::new(
1085 "id".to_string(),
1086 sample_shop(),
1087 "token".to_string(),
1088 sample_scopes(),
1089 false,
1090 None,
1091 );
1092 session.refresh_token_expires_at = Some(Utc::now() - Duration::hours(1));
1094
1095 assert!(session.refresh_token_expired());
1096 }
1097
1098 #[test]
1099 fn test_from_access_token_response_populates_refresh_token_fields() {
1100 let shop = ShopDomain::new("my-store").unwrap();
1101 let response = AccessTokenResponse {
1102 access_token: "access-token".to_string(),
1103 scope: "read_products".to_string(),
1104 expires_in: Some(86400), associated_user_scope: None,
1106 associated_user: None,
1107 session: None,
1108 refresh_token: Some("refresh-token-xyz".to_string()),
1109 refresh_token_expires_in: Some(2592000), };
1111
1112 let before = Utc::now();
1113 let session = Session::from_access_token_response(shop, &response);
1114 let after = Utc::now();
1115
1116 assert_eq!(session.refresh_token, Some("refresh-token-xyz".to_string()));
1117 assert!(session.refresh_token_expires_at.is_some());
1118
1119 let expires_at = session.refresh_token_expires_at.unwrap();
1120 let expected_min = before + Duration::seconds(2592000);
1121 let expected_max = after + Duration::seconds(2592000);
1122
1123 assert!(expires_at >= expected_min && expires_at <= expected_max);
1124 }
1125
1126 #[test]
1127 fn test_access_token_response_deserializes_refresh_token_field() {
1128 let json = r#"{
1129 "access_token": "test-token",
1130 "scope": "read_products",
1131 "refresh_token": "refresh-abc"
1132 }"#;
1133
1134 let response: AccessTokenResponse = serde_json::from_str(json).unwrap();
1135 assert_eq!(response.refresh_token, Some("refresh-abc".to_string()));
1136 }
1137
1138 #[test]
1139 fn test_access_token_response_deserializes_refresh_token_expires_in_field() {
1140 let json = r#"{
1141 "access_token": "test-token",
1142 "scope": "read_products",
1143 "refresh_token_expires_in": 2592000
1144 }"#;
1145
1146 let response: AccessTokenResponse = serde_json::from_str(json).unwrap();
1147 assert_eq!(response.refresh_token_expires_in, Some(2592000));
1148 }
1149
1150 #[test]
1151 fn test_access_token_response_handles_missing_optional_refresh_token_fields() {
1152 let json = r#"{
1153 "access_token": "test-token",
1154 "scope": "read_products"
1155 }"#;
1156
1157 let response: AccessTokenResponse = serde_json::from_str(json).unwrap();
1158 assert!(response.refresh_token.is_none());
1159 assert!(response.refresh_token_expires_in.is_none());
1160 }
1161
1162 #[test]
1163 fn test_refresh_token_expired_at_boundary_61_seconds_is_false() {
1164 let mut session = Session::new(
1166 "id".to_string(),
1167 sample_shop(),
1168 "token".to_string(),
1169 sample_scopes(),
1170 false,
1171 None,
1172 );
1173 session.refresh_token_expires_at = Some(Utc::now() + Duration::seconds(61));
1175
1176 assert!(!session.refresh_token_expired());
1178 }
1179
1180 #[test]
1181 fn test_refresh_token_expired_at_58_seconds_is_true() {
1182 let mut session = Session::new(
1184 "id".to_string(),
1185 sample_shop(),
1186 "token".to_string(),
1187 sample_scopes(),
1188 false,
1189 None,
1190 );
1191 session.refresh_token_expires_at = Some(Utc::now() + Duration::seconds(58));
1193
1194 assert!(session.refresh_token_expired());
1196 }
1197
1198 #[test]
1199 fn test_session_roundtrip_serialization_with_refresh_token() {
1200 let mut original = Session::new(
1201 "offline_test-shop.myshopify.com".to_string(),
1202 sample_shop(),
1203 "access-token-123".to_string(),
1204 sample_scopes(),
1205 false,
1206 None,
1207 );
1208 original.refresh_token = Some("refresh-token-xyz".to_string());
1209 original.refresh_token_expires_at = Some(Utc::now() + Duration::days(30));
1210
1211 let json = serde_json::to_string(&original).unwrap();
1212 let restored: Session = serde_json::from_str(&json).unwrap();
1213
1214 assert_eq!(original.refresh_token, restored.refresh_token);
1215 assert_eq!(
1216 original.refresh_token_expires_at,
1217 restored.refresh_token_expires_at
1218 );
1219 }
1220}