spotify_cli/oauth/
token.rs1use serde::{Deserialize, Serialize};
4use std::time::{Duration, SystemTime, UNIX_EPOCH};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Token {
11 pub access_token: String,
13 pub token_type: String,
15 pub scope: String,
17 pub expires_at: u64,
19 pub refresh_token: Option<String>,
21}
22
23#[derive(Debug, Deserialize)]
25pub struct SpotifyTokenResponse {
26 pub access_token: String,
27 pub token_type: String,
28 pub scope: String,
29 pub expires_in: u64,
31 pub refresh_token: Option<String>,
32}
33
34impl Token {
35 pub fn from_response(response: SpotifyTokenResponse) -> Self {
39 let expires_at = current_timestamp() + response.expires_in;
40
41 Self {
42 access_token: response.access_token,
43 token_type: response.token_type,
44 scope: response.scope,
45 expires_at,
46 refresh_token: response.refresh_token,
47 }
48 }
49
50 pub fn is_expired(&self) -> bool {
54 use crate::constants::TOKEN_EXPIRY_BUFFER_SECS;
55 let now = current_timestamp();
56
57 now + TOKEN_EXPIRY_BUFFER_SECS >= self.expires_at
58 }
59
60 pub fn seconds_until_expiry(&self) -> i64 {
64 let now = current_timestamp();
65 self.expires_at as i64 - now as i64
66 }
67}
68
69fn current_timestamp() -> u64 {
70 SystemTime::now()
71 .duration_since(UNIX_EPOCH)
72 .unwrap_or(Duration::ZERO)
73 .as_secs()
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79
80 fn make_token(expires_in: u64) -> Token {
81 let response = SpotifyTokenResponse {
82 access_token: "test_access".to_string(),
83 token_type: "Bearer".to_string(),
84 scope: "user-read-playback-state".to_string(),
85 expires_in,
86 refresh_token: Some("test_refresh".to_string()),
87 };
88
89 Token::from_response(response)
90 }
91
92 #[test]
93 fn fresh_token_is_not_expired() {
94 let token = make_token(3600);
95 assert!(!token.is_expired());
96 }
97
98 #[test]
99 fn token_expiring_soon_is_expired() {
100 let token = make_token(30);
101 assert!(token.is_expired());
102 }
103
104 #[test]
105 fn seconds_until_expiry_is_positive_for_fresh_token() {
106 let token = make_token(3600);
107 assert!(token.seconds_until_expiry() > 0);
108 }
109
110 #[test]
111 fn token_from_response_sets_fields() {
112 let response = SpotifyTokenResponse {
113 access_token: "access123".to_string(),
114 token_type: "Bearer".to_string(),
115 scope: "scope1 scope2".to_string(),
116 expires_in: 3600,
117 refresh_token: Some("refresh456".to_string()),
118 };
119
120 let token = Token::from_response(response);
121 assert_eq!(token.access_token, "access123");
122 assert_eq!(token.token_type, "Bearer");
123 assert_eq!(token.scope, "scope1 scope2");
124 assert_eq!(token.refresh_token, Some("refresh456".to_string()));
125 }
126
127 #[test]
128 fn token_from_response_without_refresh_token() {
129 let response = SpotifyTokenResponse {
130 access_token: "access123".to_string(),
131 token_type: "Bearer".to_string(),
132 scope: "scope1".to_string(),
133 expires_in: 3600,
134 refresh_token: None,
135 };
136
137 let token = Token::from_response(response);
138 assert!(token.refresh_token.is_none());
139 }
140
141 #[test]
142 fn token_serializes_to_json() {
143 let token = make_token(3600);
144 let json = serde_json::to_value(&token).unwrap();
145 assert!(json.get("access_token").is_some());
146 assert!(json.get("token_type").is_some());
147 assert!(json.get("scope").is_some());
148 assert!(json.get("expires_at").is_some());
149 }
150
151 #[test]
152 fn token_deserializes_from_json() {
153 let now = SystemTime::now()
154 .duration_since(UNIX_EPOCH)
155 .unwrap()
156 .as_secs();
157
158 let json = serde_json::json!({
159 "access_token": "access123",
160 "token_type": "Bearer",
161 "scope": "user-read-playback-state",
162 "expires_at": now + 3600,
163 "refresh_token": "refresh456"
164 });
165
166 let token: Token = serde_json::from_value(json).unwrap();
167 assert_eq!(token.access_token, "access123");
168 assert!(!token.is_expired());
169 }
170
171 #[test]
172 fn expired_token_is_expired() {
173 let now = SystemTime::now()
174 .duration_since(UNIX_EPOCH)
175 .unwrap()
176 .as_secs();
177
178 let token = Token {
179 access_token: "expired".to_string(),
180 token_type: "Bearer".to_string(),
181 scope: "scope".to_string(),
182 expires_at: now - 100, refresh_token: None,
184 };
185
186 assert!(token.is_expired());
187 }
188
189 #[test]
190 fn seconds_until_expiry_negative_for_expired() {
191 let now = SystemTime::now()
192 .duration_since(UNIX_EPOCH)
193 .unwrap()
194 .as_secs();
195
196 let token = Token {
197 access_token: "expired".to_string(),
198 token_type: "Bearer".to_string(),
199 scope: "scope".to_string(),
200 expires_at: now - 100,
201 refresh_token: None,
202 };
203
204 assert!(token.seconds_until_expiry() < 0);
205 }
206}