Skip to main content

shopify_sdk/auth/
session.rs

1//! Session management for Shopify API authentication.
2//!
3//! This module provides the [`Session`] type for representing authenticated
4//! sessions used in API calls, along with helper methods for session ID generation
5//! and factory methods for creating sessions from OAuth responses.
6//!
7//! # Session Types
8//!
9//! Shopify supports two types of access tokens, each with its own session pattern:
10//!
11//! - **Offline sessions** (`is_online = false`):
12//!   - App-level tokens that persist indefinitely (or until expiration for expiring tokens)
13//!   - ID format: `"offline_{shop}"` (e.g., `"offline_my-store.myshopify.com"`)
14//!   - May include refresh tokens for token refresh flow
15//!   - Used for background tasks, webhooks, and server-side operations
16//!
17//! - **Online sessions** (`is_online = true`):
18//!   - User-specific tokens that expire
19//!   - ID format: `"{shop}_{user_id}"` (e.g., `"my-store.myshopify.com_12345"`)
20//!   - Include expiration time and associated user information
21//!   - Used for user-facing operations where user identity matters
22//!
23//! # Immutability
24//!
25//! Sessions are immutable after creation. To "update" a session, create a new
26//! `Session` instance. This design ensures thread safety and prevents accidental
27//! mutation of authentication state.
28//!
29//! # Example
30//!
31//! ```rust
32//! use shopify_sdk::{Session, ShopDomain, AuthScopes};
33//!
34//! // Create an offline session with generated ID
35//! let shop = ShopDomain::new("my-store").unwrap();
36//! let session = Session::new(
37//!     Session::generate_offline_id(&shop),
38//!     shop,
39//!     "access-token".to_string(),
40//!     "read_products".parse().unwrap(),
41//!     false,
42//!     None,
43//! );
44//!
45//! assert_eq!(session.id, "offline_my-store.myshopify.com");
46//! assert!(session.is_active());
47//! ```
48
49use crate::auth::associated_user::AssociatedUser;
50use crate::auth::AuthScopes;
51use crate::config::ShopDomain;
52use chrono::{DateTime, Duration, Utc};
53use serde::{Deserialize, Serialize};
54
55/// Buffer time (in seconds) before considering a refresh token expired.
56/// Matches the Ruby SDK's behavior.
57const REFRESH_TOKEN_EXPIRY_BUFFER_SECONDS: i64 = 60;
58
59/// Represents an authenticated session for Shopify API calls.
60///
61/// Sessions hold the authentication state needed to make API requests on behalf
62/// of a shop. They can be either online (user-specific) or offline (app-level).
63///
64/// # Thread Safety
65///
66/// `Session` is `Send + Sync`, making it safe to share across threads.
67///
68/// # Serialization
69///
70/// Sessions can be serialized to JSON for storage and deserialized when needed:
71///
72/// ```rust
73/// use shopify_sdk::{Session, ShopDomain, AuthScopes};
74///
75/// let session = Session::new(
76///     "session-id".to_string(),
77///     ShopDomain::new("my-store").unwrap(),
78///     "access-token".to_string(),
79///     "read_products".parse().unwrap(),
80///     false,
81///     None,
82/// );
83///
84/// // Serialize to JSON
85/// let json = serde_json::to_string(&session).unwrap();
86///
87/// // Deserialize from JSON
88/// let restored: Session = serde_json::from_str(&json).unwrap();
89/// assert_eq!(session, restored);
90/// ```
91///
92/// # Example
93///
94/// ```rust
95/// use shopify_sdk::{Session, ShopDomain, AuthScopes};
96///
97/// let session = Session::new(
98///     "session-id".to_string(),
99///     ShopDomain::new("my-store").unwrap(),
100///     "access-token".to_string(),
101///     "read_products".parse().unwrap(),
102///     false, // offline session
103///     None,  // no expiration
104/// );
105///
106/// assert!(session.is_active());
107/// assert!(!session.expired());
108/// ```
109#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
110pub struct Session {
111    /// Unique identifier for this session.
112    ///
113    /// For offline sessions: `"offline_{shop}"` (e.g., `"offline_my-store.myshopify.com"`)
114    /// For online sessions: `"{shop}_{user_id}"` (e.g., `"my-store.myshopify.com_12345"`)
115    pub id: String,
116
117    /// The shop this session is for.
118    pub shop: ShopDomain,
119
120    /// The access token for API authentication.
121    pub access_token: String,
122
123    /// The OAuth scopes granted to this session.
124    pub scopes: AuthScopes,
125
126    /// Whether this is an online (user-specific) session.
127    pub is_online: bool,
128
129    /// When this session expires, if applicable.
130    ///
131    /// Offline sessions have `None` (they don't expire) unless using expiring tokens.
132    /// Online sessions have a specific expiration time.
133    pub expires: Option<DateTime<Utc>>,
134
135    /// OAuth state parameter, if applicable.
136    ///
137    /// Used during the OAuth flow for CSRF protection.
138    pub state: Option<String>,
139
140    /// Shopify-provided session ID, if applicable.
141    pub shopify_session_id: Option<String>,
142
143    /// User information for online sessions.
144    ///
145    /// Only present for online sessions (when `is_online` is `true`).
146    pub associated_user: Option<AssociatedUser>,
147
148    /// User-specific scopes for online sessions.
149    ///
150    /// These may be different from the app's granted scopes, representing
151    /// what the specific user is allowed to do.
152    pub associated_user_scopes: Option<AuthScopes>,
153
154    /// The refresh token for obtaining new access tokens.
155    ///
156    /// Only present for expiring offline tokens. Use with [`refresh_access_token`]
157    /// to obtain a new access token before the current one expires.
158    ///
159    /// [`refresh_access_token`]: crate::auth::oauth::refresh_access_token
160    #[serde(default)]
161    pub refresh_token: Option<String>,
162
163    /// When the refresh token expires, if applicable.
164    ///
165    /// `None` indicates the refresh token does not expire or is not present.
166    /// Use [`refresh_token_expired`](Session::refresh_token_expired) to check
167    /// if the refresh token needs to be renewed.
168    #[serde(default)]
169    pub refresh_token_expires_at: Option<DateTime<Utc>>,
170}
171
172impl Session {
173    /// Creates a new session with the specified parameters.
174    ///
175    /// This constructor maintains backward compatibility with existing code.
176    /// New fields (`associated_user`, `associated_user_scopes`, `refresh_token`,
177    /// and `refresh_token_expires_at`) default to `None`.
178    ///
179    /// For online sessions with user information, use [`Session::with_user`] instead.
180    ///
181    /// # Arguments
182    ///
183    /// * `id` - Unique session identifier
184    /// * `shop` - The shop domain
185    /// * `access_token` - The access token for API calls
186    /// * `scopes` - OAuth scopes granted to this session
187    /// * `is_online` - Whether this is a user-specific session
188    /// * `expires` - When this session expires (None for offline sessions)
189    ///
190    /// # Example
191    ///
192    /// ```rust
193    /// use shopify_sdk::{Session, ShopDomain, AuthScopes};
194    ///
195    /// let session = Session::new(
196    ///     "offline_my-store.myshopify.com".to_string(),
197    ///     ShopDomain::new("my-store").unwrap(),
198    ///     "access-token".to_string(),
199    ///     "read_products".parse().unwrap(),
200    ///     false,
201    ///     None,
202    /// );
203    /// ```
204    #[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    /// Creates a new online session with associated user information.
230    ///
231    /// This is a convenience constructor for online sessions that includes
232    /// user details and user-specific scopes.
233    ///
234    /// # Arguments
235    ///
236    /// * `id` - Unique session identifier (typically `"{shop}_{user_id}"`)
237    /// * `shop` - The shop domain
238    /// * `access_token` - The access token for API calls
239    /// * `scopes` - OAuth scopes granted to this session
240    /// * `expires` - When this session expires
241    /// * `associated_user` - The user who authorized this session
242    /// * `associated_user_scopes` - User-specific scopes (if different from app scopes)
243    ///
244    /// # Example
245    ///
246    /// ```rust
247    /// use shopify_sdk::{Session, ShopDomain, AuthScopes, AssociatedUser};
248    /// use chrono::{Utc, Duration};
249    ///
250    /// let shop = ShopDomain::new("my-store").unwrap();
251    /// let user = AssociatedUser::new(
252    ///     12345,
253    ///     "Jane".to_string(),
254    ///     "Doe".to_string(),
255    ///     "jane@example.com".to_string(),
256    ///     true, true, "en".to_string(), false,
257    /// );
258    ///
259    /// let session = Session::with_user(
260    ///     Session::generate_online_id(&shop, 12345),
261    ///     shop,
262    ///     "access-token".to_string(),
263    ///     "read_products".parse().unwrap(),
264    ///     Some(Utc::now() + Duration::hours(1)),
265    ///     user,
266    ///     Some("read_products".parse().unwrap()),
267    /// );
268    ///
269    /// assert!(session.is_online);
270    /// assert!(session.associated_user.is_some());
271    /// ```
272    #[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    /// Generates a session ID for an offline session.
300    ///
301    /// The ID format is `"offline_{shop}"` where `{shop}` is the full shop domain.
302    ///
303    /// # Example
304    ///
305    /// ```rust
306    /// use shopify_sdk::{Session, ShopDomain};
307    ///
308    /// let shop = ShopDomain::new("my-store").unwrap();
309    /// let id = Session::generate_offline_id(&shop);
310    /// assert_eq!(id, "offline_my-store.myshopify.com");
311    /// ```
312    #[must_use]
313    pub fn generate_offline_id(shop: &ShopDomain) -> String {
314        format!("offline_{}", shop.as_ref())
315    }
316
317    /// Generates a session ID for an online session.
318    ///
319    /// The ID format is `"{shop}_{user_id}"` where `{shop}` is the full shop domain
320    /// and `{user_id}` is the Shopify user ID.
321    ///
322    /// # Example
323    ///
324    /// ```rust
325    /// use shopify_sdk::{Session, ShopDomain};
326    ///
327    /// let shop = ShopDomain::new("my-store").unwrap();
328    /// let id = Session::generate_online_id(&shop, 12345);
329    /// assert_eq!(id, "my-store.myshopify.com_12345");
330    /// ```
331    #[must_use]
332    pub fn generate_online_id(shop: &ShopDomain, user_id: u64) -> String {
333        format!("{}_{}", shop.as_ref(), user_id)
334    }
335
336    /// Creates a session from an OAuth access token response.
337    ///
338    /// This factory method automatically:
339    /// - Generates the appropriate session ID based on session type
340    /// - Parses scopes from the response
341    /// - Calculates expiration time from `expires_in` seconds
342    /// - Sets `is_online` based on presence of associated user
343    /// - Populates refresh token fields if present in the response
344    ///
345    /// # Arguments
346    ///
347    /// * `shop` - The shop domain
348    /// * `response` - The OAuth access token response from Shopify
349    ///
350    /// # Example
351    ///
352    /// ```rust
353    /// use shopify_sdk::{Session, ShopDomain};
354    /// use shopify_sdk::auth::session::AccessTokenResponse;
355    ///
356    /// let shop = ShopDomain::new("my-store").unwrap();
357    /// let response = AccessTokenResponse {
358    ///     access_token: "access-token".to_string(),
359    ///     scope: "read_products,write_orders".to_string(),
360    ///     expires_in: None,
361    ///     associated_user_scope: None,
362    ///     associated_user: None,
363    ///     session: None,
364    ///     refresh_token: None,
365    ///     refresh_token_expires_in: None,
366    /// };
367    ///
368    /// let session = Session::from_access_token_response(shop, &response);
369    /// assert!(!session.is_online);
370    /// assert_eq!(session.id, "offline_my-store.myshopify.com");
371    /// ```
372    #[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    /// Returns `true` if this session has expired.
426    ///
427    /// Sessions without an expiration time are considered never expired.
428    #[must_use]
429    pub fn expired(&self) -> bool {
430        self.expires.is_some_and(|expires| Utc::now() > expires)
431    }
432
433    /// Returns `true` if this session is active (not expired and has access token).
434    #[must_use]
435    pub fn is_active(&self) -> bool {
436        !self.access_token.is_empty() && !self.expired()
437    }
438
439    /// Returns `true` if the refresh token has expired or will expire within 60 seconds.
440    ///
441    /// This method uses a 60-second buffer (matching the Ruby SDK) to ensure
442    /// you have time to refresh the token before it actually expires.
443    ///
444    /// Returns `false` if:
445    /// - No `refresh_token_expires_at` is set (token doesn't expire)
446    /// - The refresh token has more than 60 seconds before expiration
447    ///
448    /// # Example
449    ///
450    /// ```rust
451    /// use shopify_sdk::{Session, ShopDomain, AuthScopes};
452    /// use chrono::{Utc, Duration};
453    ///
454    /// // Session without refresh token expiration
455    /// let session = Session::new(
456    ///     "session-id".to_string(),
457    ///     ShopDomain::new("my-store").unwrap(),
458    ///     "access-token".to_string(),
459    ///     AuthScopes::new(),
460    ///     false,
461    ///     None,
462    /// );
463    /// assert!(!session.refresh_token_expired());
464    /// ```
465    #[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/// OAuth access token response from Shopify.
475///
476/// This struct represents the response from Shopify's OAuth token endpoint.
477/// It is used with [`Session::from_access_token_response`] to create sessions.
478///
479/// Note: This struct is defined here temporarily and may be moved to an
480/// OAuth module in a future release.
481#[derive(Clone, Debug, Deserialize)]
482pub struct AccessTokenResponse {
483    /// The access token for API calls.
484    pub access_token: String,
485
486    /// Comma-separated list of granted scopes.
487    pub scope: String,
488
489    /// Number of seconds until the token expires (online tokens only).
490    pub expires_in: Option<u32>,
491
492    /// Comma-separated list of user-specific scopes (online tokens only).
493    pub associated_user_scope: Option<String>,
494
495    /// Associated user information (online tokens only).
496    pub associated_user: Option<AssociatedUserResponse>,
497
498    /// Shopify-provided session ID.
499    #[serde(rename = "session")]
500    pub session: Option<String>,
501
502    /// The refresh token for obtaining new access tokens.
503    ///
504    /// Only present for expiring offline tokens.
505    pub refresh_token: Option<String>,
506
507    /// Number of seconds until the refresh token expires.
508    ///
509    /// Only present for expiring offline tokens.
510    pub refresh_token_expires_in: Option<u32>,
511}
512
513/// User information from an OAuth access token response.
514///
515/// This struct matches the format of user data in Shopify's OAuth response.
516#[derive(Clone, Debug, Deserialize)]
517pub struct AssociatedUserResponse {
518    /// The Shopify user ID.
519    pub id: u64,
520
521    /// The user's first name.
522    pub first_name: String,
523
524    /// The user's last name.
525    pub last_name: String,
526
527    /// The user's email address.
528    pub email: String,
529
530    /// Whether the user's email has been verified.
531    pub email_verified: bool,
532
533    /// Whether the user is the account owner.
534    pub account_owner: bool,
535
536    /// The user's locale preference.
537    pub locale: String,
538
539    /// Whether the user is a collaborator.
540    pub collaborator: bool,
541}
542
543// Verify Session is Send + Sync at compile time
544const _: 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    // === Existing Session tests ===
575
576    #[test]
577    fn test_session_expired() {
578        // Expired session
579        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        // Not expired session
590        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        // No expiration
601        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        // Active session
615        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        // Inactive due to empty token
626        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        // Inactive due to expiration
637        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    // === Task Group 3: Extended Session tests ===
655
656    #[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        // Verify refresh token fields default to None
734        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        // Different ID should not be equal
761        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    // === Task Group 4: ID Generation and Factory Method tests ===
802
803    #[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), // 1 hour
887            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        // Expires should be roughly 1 hour from now
911        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        // write_orders implies read_orders
936        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        // Offline response
944        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        // Online response
958        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    // === Refresh token tests ===
982
983    #[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        // Old format without refresh token fields
1019        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        // Set refresh token expires at 2 hours from now (well past 60 second buffer)
1061        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        // Set refresh token expires at 30 seconds from now (within 60 second buffer)
1077        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        // Set refresh token expires at 1 hour ago
1093        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), // 24 hours
1105            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), // 30 days
1110        };
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        // Use 61 seconds to avoid timing issues (1 second buffer)
1165        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        // Set refresh token expires at 61 seconds from now (just past buffer)
1174        session.refresh_token_expires_at = Some(Utc::now() + Duration::seconds(61));
1175
1176        // Should NOT be expired (61 > 60)
1177        assert!(!session.refresh_token_expired());
1178    }
1179
1180    #[test]
1181    fn test_refresh_token_expired_at_58_seconds_is_true() {
1182        // Use 58 seconds to avoid timing issues (within buffer)
1183        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        // Set refresh token expires at 58 seconds from now (within buffer)
1192        session.refresh_token_expires_at = Some(Utc::now() + Duration::seconds(58));
1193
1194        // Should be expired (58 < 60)
1195        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}