tastytrade 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/commands/release-pr.md +108 -0
  3. data/.github/ISSUE_TEMPLATE/feature_request.md +29 -0
  4. data/.github/ISSUE_TEMPLATE/roadmap_task.md +34 -0
  5. data/.github/dependabot.yml +11 -0
  6. data/.github/workflows/main.yml +75 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +101 -0
  9. data/.ruby-version +1 -0
  10. data/CHANGELOG.md +100 -0
  11. data/CLAUDE.md +78 -0
  12. data/CODE_OF_CONDUCT.md +81 -0
  13. data/CONTRIBUTING.md +89 -0
  14. data/DISCLAIMER.md +54 -0
  15. data/LICENSE.txt +24 -0
  16. data/README.md +235 -0
  17. data/ROADMAP.md +157 -0
  18. data/Rakefile +17 -0
  19. data/SECURITY.md +48 -0
  20. data/docs/getting_started.md +48 -0
  21. data/docs/python_sdk_analysis.md +181 -0
  22. data/exe/tastytrade +8 -0
  23. data/lib/tastytrade/cli.rb +604 -0
  24. data/lib/tastytrade/cli_config.rb +79 -0
  25. data/lib/tastytrade/cli_helpers.rb +178 -0
  26. data/lib/tastytrade/client.rb +117 -0
  27. data/lib/tastytrade/keyring_store.rb +72 -0
  28. data/lib/tastytrade/models/account.rb +129 -0
  29. data/lib/tastytrade/models/account_balance.rb +75 -0
  30. data/lib/tastytrade/models/base.rb +47 -0
  31. data/lib/tastytrade/models/current_position.rb +155 -0
  32. data/lib/tastytrade/models/user.rb +23 -0
  33. data/lib/tastytrade/models.rb +7 -0
  34. data/lib/tastytrade/session.rb +164 -0
  35. data/lib/tastytrade/session_manager.rb +160 -0
  36. data/lib/tastytrade/version.rb +5 -0
  37. data/lib/tastytrade.rb +31 -0
  38. data/sig/tastytrade.rbs +4 -0
  39. data/spec/exe/tastytrade_spec.rb +104 -0
  40. data/spec/spec_helper.rb +26 -0
  41. data/spec/tastytrade/cli_accounts_spec.rb +166 -0
  42. data/spec/tastytrade/cli_auth_spec.rb +216 -0
  43. data/spec/tastytrade/cli_config_spec.rb +180 -0
  44. data/spec/tastytrade/cli_helpers_spec.rb +248 -0
  45. data/spec/tastytrade/cli_interactive_spec.rb +54 -0
  46. data/spec/tastytrade/cli_logout_spec.rb +121 -0
  47. data/spec/tastytrade/cli_select_spec.rb +174 -0
  48. data/spec/tastytrade/cli_status_spec.rb +206 -0
  49. data/spec/tastytrade/client_spec.rb +210 -0
  50. data/spec/tastytrade/keyring_store_spec.rb +168 -0
  51. data/spec/tastytrade/models/account_balance_spec.rb +247 -0
  52. data/spec/tastytrade/models/account_spec.rb +206 -0
  53. data/spec/tastytrade/models/base_spec.rb +61 -0
  54. data/spec/tastytrade/models/current_position_spec.rb +444 -0
  55. data/spec/tastytrade/models/user_spec.rb +58 -0
  56. data/spec/tastytrade/session_manager_spec.rb +296 -0
  57. data/spec/tastytrade/session_spec.rb +392 -0
  58. data/spec/tastytrade_spec.rb +9 -0
  59. metadata +303 -0
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tastytrade/session_manager"
5
+ require "tastytrade/keyring_store"
6
+ require "tastytrade/cli_config"
7
+
8
+ RSpec.describe Tastytrade::SessionManager do
9
+ let(:username) { "test@example.com" }
10
+ let(:environment) { "production" }
11
+ let(:manager) { described_class.new(username: username, environment: environment) }
12
+ let(:session) { instance_double(Tastytrade::Session) }
13
+ let(:config) { instance_double(Tastytrade::CLIConfig) }
14
+
15
+ before do
16
+ allow(Tastytrade::CLIConfig).to receive(:new).and_return(config)
17
+ allow(config).to receive(:set)
18
+ allow(config).to receive(:delete)
19
+ allow(Tastytrade::KeyringStore).to receive(:available?).and_return(true)
20
+ end
21
+
22
+ describe "#initialize" do
23
+ it "sets username and environment" do
24
+ expect(manager.username).to eq(username)
25
+ expect(manager.environment).to eq(environment)
26
+ end
27
+
28
+ it "defaults to production environment" do
29
+ manager = described_class.new(username: username)
30
+ expect(manager.environment).to eq("production")
31
+ end
32
+ end
33
+
34
+ describe "#save_session" do
35
+ let(:session_token) { "session_token_123" }
36
+ let(:remember_token) { "remember_token_456" }
37
+
38
+ before do
39
+ allow(session).to receive(:session_token).and_return(session_token)
40
+ allow(session).to receive(:remember_token).and_return(remember_token)
41
+ allow(session).to receive(:session_expiration).and_return(nil)
42
+ allow(Tastytrade::KeyringStore).to receive(:set).and_return(true)
43
+ end
44
+
45
+ it "saves session token" do
46
+ expect(Tastytrade::KeyringStore).to receive(:set)
47
+ .with("token_test@example.com_production", session_token)
48
+
49
+ manager.save_session(session)
50
+ end
51
+
52
+ context "with session expiration" do
53
+ let(:expiration_time) { Time.now + 3600 }
54
+
55
+ before do
56
+ allow(session).to receive(:session_expiration).and_return(expiration_time)
57
+ end
58
+
59
+ it "saves session expiration" do
60
+ expect(Tastytrade::KeyringStore).to receive(:set)
61
+ .with("session_expiration_test@example.com_production", expiration_time.iso8601)
62
+
63
+ manager.save_session(session)
64
+ end
65
+ end
66
+
67
+ it "saves config data" do
68
+ expect(config).to receive(:set).with("current_username", username)
69
+ expect(config).to receive(:set).with("environment", environment)
70
+ expect(config).to receive(:set).with("last_login", anything)
71
+
72
+ manager.save_session(session)
73
+ end
74
+
75
+ context "with remember option" do
76
+ it "saves remember token and password" do
77
+ expect(Tastytrade::KeyringStore).to receive(:set)
78
+ .with("remember_test@example.com_production", remember_token)
79
+ expect(Tastytrade::KeyringStore).to receive(:set)
80
+ .with("password_test@example.com_production", "secret123")
81
+
82
+ manager.save_session(session, password: "secret123", remember: true)
83
+ end
84
+
85
+ it "doesn't save password if keyring unavailable" do
86
+ allow(Tastytrade::KeyringStore).to receive(:available?).and_return(false)
87
+
88
+ expect(Tastytrade::KeyringStore).not_to receive(:set)
89
+ .with("password_test@example.com_production", anything)
90
+
91
+ manager.save_session(session, password: "secret123", remember: true)
92
+ end
93
+ end
94
+
95
+ context "without remember option" do
96
+ it "doesn't save remember token or password" do
97
+ expect(Tastytrade::KeyringStore).not_to receive(:set)
98
+ .with("remember_test@example.com_production", anything)
99
+ expect(Tastytrade::KeyringStore).not_to receive(:set)
100
+ .with("password_test@example.com_production", anything)
101
+
102
+ manager.save_session(session, password: "secret123", remember: false)
103
+ end
104
+ end
105
+
106
+ it "handles errors gracefully" do
107
+ allow(Tastytrade::KeyringStore).to receive(:set).and_raise(StandardError, "Save error")
108
+
109
+ expect { manager.save_session(session) }.to output(/Failed to save session/).to_stderr
110
+ expect(manager.save_session(session)).to be false
111
+ end
112
+ end
113
+
114
+ describe "#load_session" do
115
+ context "with saved token" do
116
+ before do
117
+ allow(Tastytrade::KeyringStore).to receive(:get)
118
+ .with("token_test@example.com_production").and_return("saved_token")
119
+ allow(Tastytrade::KeyringStore).to receive(:get)
120
+ .with("remember_test@example.com_production").and_return("saved_remember")
121
+ allow(Tastytrade::KeyringStore).to receive(:get)
122
+ .with("session_expiration_test@example.com_production").and_return(nil)
123
+ end
124
+
125
+ it "returns session data hash" do
126
+ result = manager.load_session
127
+
128
+ expect(result).to eq({
129
+ session_token: "saved_token",
130
+ remember_token: "saved_remember",
131
+ session_expiration: nil,
132
+ username: username,
133
+ environment: environment
134
+ })
135
+ end
136
+ end
137
+
138
+ context "without saved token" do
139
+ before do
140
+ allow(Tastytrade::KeyringStore).to receive(:get)
141
+ .with("token_test@example.com_production").and_return(nil)
142
+ end
143
+
144
+ it "returns nil" do
145
+ expect(manager.load_session).to be_nil
146
+ end
147
+ end
148
+ end
149
+
150
+ describe "#restore_session" do
151
+ let(:new_session) { instance_double(Tastytrade::Session) }
152
+
153
+ before do
154
+ allow(Tastytrade::Session).to receive(:new).and_return(new_session)
155
+ allow(new_session).to receive(:login).and_return(new_session)
156
+ end
157
+
158
+ context "with saved remember token" do
159
+ before do
160
+ allow(Tastytrade::KeyringStore).to receive(:get)
161
+ .with("password_test@example.com_production").and_return(nil)
162
+ allow(Tastytrade::KeyringStore).to receive(:get)
163
+ .with("remember_test@example.com_production").and_return("saved_remember")
164
+ end
165
+
166
+ it "creates session with remember token" do
167
+ expect(Tastytrade::Session).to receive(:new).with(
168
+ username: username,
169
+ password: nil,
170
+ remember_token: "saved_remember",
171
+ is_test: false
172
+ )
173
+
174
+ manager.restore_session
175
+ end
176
+ end
177
+
178
+ context "with saved password" do
179
+ before do
180
+ allow(Tastytrade::KeyringStore).to receive(:get)
181
+ .with("remember_test@example.com_production").and_return(nil)
182
+ allow(Tastytrade::KeyringStore).to receive(:get)
183
+ .with("password_test@example.com_production").and_return("saved_password")
184
+ end
185
+
186
+ it "creates session with password" do
187
+ expect(Tastytrade::Session).to receive(:new).with(
188
+ username: username,
189
+ password: "saved_password",
190
+ remember_token: nil,
191
+ is_test: false
192
+ )
193
+
194
+ manager.restore_session
195
+ end
196
+ end
197
+
198
+ context "with sandbox environment" do
199
+ let(:environment) { "sandbox" }
200
+
201
+ before do
202
+ allow(Tastytrade::KeyringStore).to receive(:get)
203
+ .with("password_test@example.com_sandbox").and_return("saved_password")
204
+ allow(Tastytrade::KeyringStore).to receive(:get)
205
+ .with("remember_test@example.com_sandbox").and_return(nil)
206
+ end
207
+
208
+ it "sets is_test flag" do
209
+ expect(Tastytrade::Session).to receive(:new).with(
210
+ hash_including(is_test: true)
211
+ )
212
+
213
+ manager.restore_session
214
+ end
215
+ end
216
+
217
+ context "without saved credentials" do
218
+ before do
219
+ allow(Tastytrade::KeyringStore).to receive(:get).and_return(nil)
220
+ end
221
+
222
+ it "returns nil" do
223
+ expect(manager.restore_session).to be_nil
224
+ end
225
+ end
226
+
227
+ it "handles errors gracefully" do
228
+ allow(Tastytrade::KeyringStore).to receive(:get)
229
+ .with("password_test@example.com_production").and_return("password")
230
+ allow(Tastytrade::KeyringStore).to receive(:get)
231
+ .with("remember_test@example.com_production").and_return(nil)
232
+ allow(new_session).to receive(:login).and_raise(StandardError, "Login error")
233
+
234
+ expect { manager.restore_session }.to output(/Failed to restore session/).to_stderr
235
+ expect(manager.restore_session).to be_nil
236
+ end
237
+ end
238
+
239
+ describe "#clear_session!" do
240
+ it "deletes all stored credentials" do
241
+ expect(Tastytrade::KeyringStore).to receive(:delete)
242
+ .with("token_test@example.com_production")
243
+ expect(Tastytrade::KeyringStore).to receive(:delete)
244
+ .with("remember_test@example.com_production")
245
+ expect(Tastytrade::KeyringStore).to receive(:delete)
246
+ .with("password_test@example.com_production")
247
+ expect(Tastytrade::KeyringStore).to receive(:delete)
248
+ .with("session_expiration_test@example.com_production")
249
+
250
+ manager.clear_session!
251
+ end
252
+
253
+ it "clears config data" do
254
+ expect(config).to receive(:delete).with("current_username")
255
+ expect(config).to receive(:delete).with("last_login")
256
+
257
+ manager.clear_session!
258
+ end
259
+ end
260
+
261
+ describe "#saved_credentials?" do
262
+ context "with saved password" do
263
+ before do
264
+ allow(Tastytrade::KeyringStore).to receive(:get)
265
+ .with("password_test@example.com_production").and_return("password")
266
+ end
267
+
268
+ it "returns true" do
269
+ expect(manager.saved_credentials?).to be true
270
+ end
271
+ end
272
+
273
+ context "with saved remember token" do
274
+ before do
275
+ allow(Tastytrade::KeyringStore).to receive(:get)
276
+ .with("password_test@example.com_production").and_return(nil)
277
+ allow(Tastytrade::KeyringStore).to receive(:get)
278
+ .with("remember_test@example.com_production").and_return("token")
279
+ end
280
+
281
+ it "returns true" do
282
+ expect(manager.saved_credentials?).to be true
283
+ end
284
+ end
285
+
286
+ context "without saved credentials" do
287
+ before do
288
+ allow(Tastytrade::KeyringStore).to receive(:get).and_return(nil)
289
+ end
290
+
291
+ it "returns false" do
292
+ expect(manager.saved_credentials?).to be false
293
+ end
294
+ end
295
+ end
296
+ end
@@ -0,0 +1,392 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Tastytrade::Session do
6
+ let(:username) { "testuser" }
7
+ let(:password) { "testpass" }
8
+ let(:client) { instance_double(Tastytrade::Client) }
9
+
10
+ before do
11
+ allow(Tastytrade::Client).to receive(:new).and_return(client)
12
+ end
13
+
14
+ describe "#initialize" do
15
+ it "creates session with default settings" do
16
+ session = described_class.new(username: username, password: password)
17
+
18
+ expect(session.is_test).to be false
19
+ expect(session.user).to be_nil
20
+ expect(session.session_token).to be_nil
21
+ end
22
+
23
+ it "creates session with test environment" do
24
+ session = described_class.new(username: username, password: password, is_test: true)
25
+
26
+ expect(session.is_test).to be true
27
+ expect(Tastytrade::Client).to have_received(:new).with(base_url: Tastytrade::CERT_URL, timeout: 30)
28
+ end
29
+
30
+ it "creates session with remember_me" do
31
+ session = described_class.new(username: username, password: password, remember_me: true)
32
+
33
+ expect(session.remember_token).to be_nil # Not set until login
34
+ end
35
+
36
+ it "creates session with remember_token" do
37
+ remember_token = "existing-remember-token"
38
+ session = described_class.new(username: username, remember_token: remember_token)
39
+
40
+ expect(session.remember_token).to eq(remember_token)
41
+ expect(session.instance_variable_get(:@password)).to be_nil
42
+ end
43
+
44
+ it "creates session with timeout" do
45
+ session = described_class.new(username: username, password: password, timeout: 60)
46
+
47
+ expect(Tastytrade::Client).to have_received(:new).with(base_url: Tastytrade::API_URL, timeout: 60)
48
+ end
49
+ end
50
+
51
+ describe "#login" do
52
+ let(:session) { described_class.new(username: username, password: password) }
53
+ let(:login_response) do
54
+ {
55
+ "data" => {
56
+ "user" => {
57
+ "email" => "test@example.com",
58
+ "username" => "testuser",
59
+ "external-id" => "ext-123"
60
+ },
61
+ "session-token" => "test-session-token"
62
+ }
63
+ }
64
+ end
65
+
66
+ it "authenticates and sets user data" do
67
+ expect(client).to receive(:post).with("/sessions", {
68
+ "login" => username,
69
+ "password" => password,
70
+ "remember-me" => false
71
+ }).and_return(login_response)
72
+
73
+ result = session.login
74
+
75
+ expect(result).to eq(session) # Returns self for chaining
76
+ expect(session.user).to be_a(Tastytrade::Models::User)
77
+ expect(session.user.email).to eq("test@example.com")
78
+ expect(session.session_token).to eq("test-session-token")
79
+ end
80
+
81
+ context "with remember_me enabled" do
82
+ let(:session) { described_class.new(username: username, password: password, remember_me: true) }
83
+ let(:login_response) do
84
+ super().tap do |response|
85
+ response["data"]["remember-token"] = "test-remember-token"
86
+ end
87
+ end
88
+
89
+ it "stores remember token" do
90
+ expect(client).to receive(:post).with("/sessions", {
91
+ "login" => username,
92
+ "password" => password,
93
+ "remember-me" => true
94
+ }).and_return(login_response)
95
+
96
+ session.login
97
+
98
+ expect(session.remember_token).to eq("test-remember-token")
99
+ end
100
+ end
101
+
102
+ context "with remember_token authentication" do
103
+ let(:remember_token) { "existing-remember-token" }
104
+ let(:session) { described_class.new(username: username, remember_token: remember_token) }
105
+
106
+ it "authenticates using remember token" do
107
+ expect(client).to receive(:post).with("/sessions", {
108
+ "login" => username,
109
+ "remember-token" => remember_token,
110
+ "remember-me" => false
111
+ }).and_return(login_response)
112
+
113
+ session.login
114
+
115
+ expect(session.session_token).to eq("test-session-token")
116
+ end
117
+
118
+ it "does not send password when using remember token" do
119
+ expect(client).to receive(:post) do |_path, body|
120
+ expect(body).not_to have_key("password")
121
+ login_response
122
+ end
123
+
124
+ session.login
125
+ end
126
+ end
127
+
128
+ context "with session expiration" do
129
+ let(:login_response_with_expiration) do
130
+ login_response.tap do |response|
131
+ response["data"]["session-expiration"] = "2024-01-01T12:00:00Z"
132
+ end
133
+ end
134
+
135
+ it "parses and stores session expiration" do
136
+ expect(client).to receive(:post).and_return(login_response_with_expiration)
137
+
138
+ session.login
139
+
140
+ expect(session.session_expiration).to be_a(Time)
141
+ expect(session.session_expiration.iso8601).to eq("2024-01-01T12:00:00Z")
142
+ end
143
+ end
144
+ end
145
+
146
+ describe "#validate" do
147
+ let(:session) { described_class.new(username: username, password: password) }
148
+ let(:user) { instance_double(Tastytrade::Models::User, email: "test@example.com") }
149
+
150
+ before do
151
+ session.instance_variable_set(:@user, user)
152
+ session.instance_variable_set(:@session_token, "token")
153
+ end
154
+
155
+ it "returns true for valid session" do
156
+ expect(client).to receive(:get).with("/sessions/validate", {}, { "Authorization" => "token" })
157
+ .and_return({ "data" => { "email" => "test@example.com" } })
158
+
159
+ expect(session.validate).to be true
160
+ end
161
+
162
+ it "returns false for invalid session" do
163
+ expect(client).to receive(:get).with("/sessions/validate", {}, { "Authorization" => "token" })
164
+ .and_return({ "data" => { "email" => "different@example.com" } })
165
+
166
+ expect(session.validate).to be false
167
+ end
168
+
169
+ it "returns false on error" do
170
+ expect(client).to receive(:get).and_raise(Tastytrade::Error, "Unauthorized")
171
+
172
+ expect(session.validate).to be false
173
+ end
174
+ end
175
+
176
+ describe "#destroy" do
177
+ let(:session) { described_class.new(username: username, password: password) }
178
+
179
+ before do
180
+ session.instance_variable_set(:@session_token, "token")
181
+ session.instance_variable_set(:@user, "user")
182
+ session.instance_variable_set(:@remember_token, "remember")
183
+ end
184
+
185
+ it "sends DELETE request and clears session data" do
186
+ expect(client).to receive(:delete).with("/sessions", { "Authorization" => "token" })
187
+
188
+ session.destroy
189
+
190
+ expect(session.session_token).to be_nil
191
+ expect(session.user).to be_nil
192
+ expect(session.remember_token).to be_nil
193
+ end
194
+
195
+ it "does nothing if not authenticated" do
196
+ session.instance_variable_set(:@session_token, nil)
197
+
198
+ expect(client).not_to receive(:delete)
199
+
200
+ session.destroy
201
+ end
202
+ end
203
+
204
+ describe "HTTP methods" do
205
+ let(:session) { described_class.new(username: username, password: password) }
206
+ let(:auth_headers) { { "Authorization" => "token" } }
207
+
208
+ before do
209
+ session.instance_variable_set(:@session_token, "token")
210
+ end
211
+
212
+ describe "#get" do
213
+ it "makes authenticated GET request" do
214
+ expect(client).to receive(:get).with("/test", { foo: "bar" }, auth_headers)
215
+ .and_return({ "data" => "result" })
216
+
217
+ result = session.get("/test", { foo: "bar" })
218
+
219
+ expect(result).to eq({ "data" => "result" })
220
+ end
221
+ end
222
+
223
+ describe "#post" do
224
+ it "makes authenticated POST request" do
225
+ body = { key: "value" }
226
+ expect(client).to receive(:post).with("/test", body, auth_headers)
227
+ .and_return({ "data" => "result" })
228
+
229
+ result = session.post("/test", body)
230
+
231
+ expect(result).to eq({ "data" => "result" })
232
+ end
233
+ end
234
+
235
+ describe "#put" do
236
+ it "makes authenticated PUT request" do
237
+ body = { key: "value" }
238
+ expect(client).to receive(:put).with("/test", body, auth_headers)
239
+ .and_return({ "data" => "result" })
240
+
241
+ result = session.put("/test", body)
242
+
243
+ expect(result).to eq({ "data" => "result" })
244
+ end
245
+ end
246
+
247
+ describe "#delete" do
248
+ it "makes authenticated DELETE request" do
249
+ expect(client).to receive(:delete).with("/test", auth_headers)
250
+ .and_return({ "data" => "result" })
251
+
252
+ result = session.delete("/test")
253
+
254
+ expect(result).to eq({ "data" => "result" })
255
+ end
256
+ end
257
+
258
+ context "when not authenticated" do
259
+ before do
260
+ session.instance_variable_set(:@session_token, nil)
261
+ end
262
+
263
+ it "raises error on GET" do
264
+ expect { session.get("/test") }.to raise_error(Tastytrade::Error, "Not authenticated")
265
+ end
266
+
267
+ it "raises error on POST" do
268
+ expect { session.post("/test") }.to raise_error(Tastytrade::Error, "Not authenticated")
269
+ end
270
+
271
+ it "raises error on PUT" do
272
+ expect { session.put("/test") }.to raise_error(Tastytrade::Error, "Not authenticated")
273
+ end
274
+
275
+ it "raises error on DELETE" do
276
+ expect { session.delete("/test") }.to raise_error(Tastytrade::Error, "Not authenticated")
277
+ end
278
+ end
279
+ end
280
+
281
+ describe "#authenticated?" do
282
+ let(:session) { described_class.new(username: username, password: password) }
283
+
284
+ it "returns false when no session token" do
285
+ expect(session.authenticated?).to be false
286
+ end
287
+
288
+ it "returns true when session token exists" do
289
+ session.instance_variable_set(:@session_token, "token")
290
+ expect(session.authenticated?).to be true
291
+ end
292
+ end
293
+
294
+ describe "#expired?" do
295
+ let(:session) { described_class.new(username: username, password: password) }
296
+
297
+ it "returns false when no expiration is set" do
298
+ expect(session.expired?).to be false
299
+ end
300
+
301
+ it "returns false when session is not expired" do
302
+ future_time = Time.now + 3600 # 1 hour from now
303
+ session.instance_variable_set(:@session_expiration, future_time)
304
+
305
+ expect(session.expired?).to be false
306
+ end
307
+
308
+ it "returns true when session is expired" do
309
+ past_time = Time.now - 3600 # 1 hour ago
310
+ session.instance_variable_set(:@session_expiration, past_time)
311
+
312
+ expect(session.expired?).to be true
313
+ end
314
+ end
315
+
316
+ describe "#time_until_expiry" do
317
+ let(:session) { described_class.new(username: username, password: password) }
318
+
319
+ it "returns nil when no expiration is set" do
320
+ expect(session.time_until_expiry).to be_nil
321
+ end
322
+
323
+ it "returns positive seconds when session is not expired" do
324
+ future_time = Time.now + 3600 # 1 hour from now
325
+ session.instance_variable_set(:@session_expiration, future_time)
326
+
327
+ time_left = session.time_until_expiry
328
+ expect(time_left).to be > 3590 # Allow for small time difference
329
+ expect(time_left).to be <= 3600
330
+ end
331
+
332
+ it "returns negative seconds when session is expired" do
333
+ past_time = Time.now - 3600 # 1 hour ago
334
+ session.instance_variable_set(:@session_expiration, past_time)
335
+
336
+ time_left = session.time_until_expiry
337
+ expect(time_left).to be < -3590
338
+ expect(time_left).to be >= -3610 # Allow for small timing differences
339
+ end
340
+ end
341
+
342
+ describe "#refresh_session" do
343
+ let(:session) { described_class.new(username: username, password: password, remember_me: true) }
344
+ let(:refresh_response) do
345
+ {
346
+ "data" => {
347
+ "user" => {
348
+ "email" => "test@example.com",
349
+ "username" => "testuser"
350
+ },
351
+ "session-token" => "new-session-token",
352
+ "session-expiration" => "2024-01-01T12:00:00Z"
353
+ }
354
+ }
355
+ end
356
+
357
+ context "with remember token" do
358
+ before do
359
+ session.instance_variable_set(:@remember_token, "valid-remember-token")
360
+ end
361
+
362
+ it "refreshes session using remember token" do
363
+ expect(client).to receive(:post).with("/sessions", {
364
+ "login" => username,
365
+ "remember-token" => "valid-remember-token",
366
+ "remember-me" => true
367
+ }).and_return(refresh_response)
368
+
369
+ result = session.refresh_session
370
+
371
+ expect(result).to eq(session)
372
+ expect(session.session_token).to eq("new-session-token")
373
+ expect(session.instance_variable_get(:@password)).to be_nil
374
+ end
375
+
376
+ it "updates session expiration on refresh" do
377
+ expect(client).to receive(:post).and_return(refresh_response)
378
+
379
+ session.refresh_session
380
+
381
+ expect(session.session_expiration).to be_a(Time)
382
+ expect(session.session_expiration.iso8601).to eq("2024-01-01T12:00:00Z")
383
+ end
384
+ end
385
+
386
+ context "without remember token" do
387
+ it "raises error when no remember token available" do
388
+ expect { session.refresh_session }.to raise_error(Tastytrade::Error, "No remember token available")
389
+ end
390
+ end
391
+ end
392
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Tastytrade do
4
+ it "has a version number" do
5
+ expect(Tastytrade::VERSION).not_to be nil
6
+ end
7
+
8
+ # TODO: Add actual specs when implementing the SDK
9
+ end