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.
- checksums.yaml +7 -0
- data/.claude/commands/release-pr.md +108 -0
- data/.github/ISSUE_TEMPLATE/feature_request.md +29 -0
- data/.github/ISSUE_TEMPLATE/roadmap_task.md +34 -0
- data/.github/dependabot.yml +11 -0
- data/.github/workflows/main.yml +75 -0
- data/.rspec +3 -0
- data/.rubocop.yml +101 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +100 -0
- data/CLAUDE.md +78 -0
- data/CODE_OF_CONDUCT.md +81 -0
- data/CONTRIBUTING.md +89 -0
- data/DISCLAIMER.md +54 -0
- data/LICENSE.txt +24 -0
- data/README.md +235 -0
- data/ROADMAP.md +157 -0
- data/Rakefile +17 -0
- data/SECURITY.md +48 -0
- data/docs/getting_started.md +48 -0
- data/docs/python_sdk_analysis.md +181 -0
- data/exe/tastytrade +8 -0
- data/lib/tastytrade/cli.rb +604 -0
- data/lib/tastytrade/cli_config.rb +79 -0
- data/lib/tastytrade/cli_helpers.rb +178 -0
- data/lib/tastytrade/client.rb +117 -0
- data/lib/tastytrade/keyring_store.rb +72 -0
- data/lib/tastytrade/models/account.rb +129 -0
- data/lib/tastytrade/models/account_balance.rb +75 -0
- data/lib/tastytrade/models/base.rb +47 -0
- data/lib/tastytrade/models/current_position.rb +155 -0
- data/lib/tastytrade/models/user.rb +23 -0
- data/lib/tastytrade/models.rb +7 -0
- data/lib/tastytrade/session.rb +164 -0
- data/lib/tastytrade/session_manager.rb +160 -0
- data/lib/tastytrade/version.rb +5 -0
- data/lib/tastytrade.rb +31 -0
- data/sig/tastytrade.rbs +4 -0
- data/spec/exe/tastytrade_spec.rb +104 -0
- data/spec/spec_helper.rb +26 -0
- data/spec/tastytrade/cli_accounts_spec.rb +166 -0
- data/spec/tastytrade/cli_auth_spec.rb +216 -0
- data/spec/tastytrade/cli_config_spec.rb +180 -0
- data/spec/tastytrade/cli_helpers_spec.rb +248 -0
- data/spec/tastytrade/cli_interactive_spec.rb +54 -0
- data/spec/tastytrade/cli_logout_spec.rb +121 -0
- data/spec/tastytrade/cli_select_spec.rb +174 -0
- data/spec/tastytrade/cli_status_spec.rb +206 -0
- data/spec/tastytrade/client_spec.rb +210 -0
- data/spec/tastytrade/keyring_store_spec.rb +168 -0
- data/spec/tastytrade/models/account_balance_spec.rb +247 -0
- data/spec/tastytrade/models/account_spec.rb +206 -0
- data/spec/tastytrade/models/base_spec.rb +61 -0
- data/spec/tastytrade/models/current_position_spec.rb +444 -0
- data/spec/tastytrade/models/user_spec.rb +58 -0
- data/spec/tastytrade/session_manager_spec.rb +296 -0
- data/spec/tastytrade/session_spec.rb +392 -0
- data/spec/tastytrade_spec.rb +9 -0
- metadata +303 -0
@@ -0,0 +1,206 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "spec_helper"
|
4
|
+
require "tastytrade/cli"
|
5
|
+
|
6
|
+
RSpec.describe "Tastytrade::CLI status command" do
|
7
|
+
def capture_stdout
|
8
|
+
original_stdout = $stdout
|
9
|
+
$stdout = StringIO.new
|
10
|
+
yield
|
11
|
+
$stdout.string
|
12
|
+
ensure
|
13
|
+
$stdout = original_stdout
|
14
|
+
end
|
15
|
+
let(:cli) { Tastytrade::CLI.new }
|
16
|
+
let(:config) { instance_double(Tastytrade::CLIConfig) }
|
17
|
+
let(:session) { instance_double(Tastytrade::Session) }
|
18
|
+
let(:user) { instance_double(Tastytrade::Models::User, email: "test@example.com") }
|
19
|
+
|
20
|
+
before do
|
21
|
+
allow(cli).to receive(:config).and_return(config)
|
22
|
+
allow(config).to receive(:get).with("current_username").and_return("testuser")
|
23
|
+
allow(config).to receive(:get).with("environment").and_return("production")
|
24
|
+
end
|
25
|
+
|
26
|
+
describe "#status" do
|
27
|
+
context "when not authenticated" do
|
28
|
+
before do
|
29
|
+
allow(cli).to receive(:current_session).and_return(nil)
|
30
|
+
end
|
31
|
+
|
32
|
+
it "displays warning about no active session" do
|
33
|
+
expect { cli.status }.to output(/No active session/).to_stderr
|
34
|
+
end
|
35
|
+
|
36
|
+
it "suggests login command" do
|
37
|
+
expect { cli.status }.to output(/Run 'tastytrade login' to authenticate/).to_stdout
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
context "when authenticated" do
|
42
|
+
before do
|
43
|
+
allow(cli).to receive(:current_session).and_return(session)
|
44
|
+
allow(session).to receive(:user).and_return(user)
|
45
|
+
end
|
46
|
+
|
47
|
+
context "without session expiration" do
|
48
|
+
before do
|
49
|
+
allow(session).to receive(:session_expiration).and_return(nil)
|
50
|
+
allow(session).to receive(:remember_token).and_return(nil)
|
51
|
+
end
|
52
|
+
|
53
|
+
it "displays session status" do
|
54
|
+
output = capture_stdout { cli.status }
|
55
|
+
expect(output).to include("Session Status:")
|
56
|
+
expect(output).to include("User: test@example.com")
|
57
|
+
expect(output).to include("Environment: production")
|
58
|
+
expect(output).to include("Status: Active")
|
59
|
+
expect(output).to include("Expires in: Unknown")
|
60
|
+
expect(output).to include("Remember token: Not available")
|
61
|
+
expect(output).to include("Auto-refresh: Disabled")
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
context "with non-expired session" do
|
66
|
+
let(:future_time) { Time.now + 3600 }
|
67
|
+
|
68
|
+
before do
|
69
|
+
allow(session).to receive(:session_expiration).and_return(future_time)
|
70
|
+
allow(session).to receive(:expired?).and_return(false)
|
71
|
+
allow(session).to receive(:time_until_expiry).and_return(3600)
|
72
|
+
allow(session).to receive(:remember_token).and_return("token123")
|
73
|
+
end
|
74
|
+
|
75
|
+
it "displays active status with time remaining" do
|
76
|
+
output = capture_stdout { cli.status }
|
77
|
+
expect(output).to include("Status: Active")
|
78
|
+
expect(output).to include("Expires in: 1h 0m")
|
79
|
+
expect(output).to include("Remember token: Available")
|
80
|
+
expect(output).to include("Auto-refresh: Enabled")
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
context "with expired session" do
|
85
|
+
let(:past_time) { Time.now - 3600 }
|
86
|
+
|
87
|
+
before do
|
88
|
+
allow(session).to receive(:session_expiration).and_return(past_time)
|
89
|
+
allow(session).to receive(:expired?).and_return(true)
|
90
|
+
allow(session).to receive(:remember_token).and_return(nil)
|
91
|
+
end
|
92
|
+
|
93
|
+
it "displays expired status" do
|
94
|
+
output = capture_stdout { cli.status }
|
95
|
+
expect(output).to include("Status: Expired")
|
96
|
+
expect(output).to include("Remember token: Not available")
|
97
|
+
expect(output).to include("Auto-refresh: Disabled")
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
describe "#refresh" do
|
104
|
+
context "when not authenticated" do
|
105
|
+
before do
|
106
|
+
allow(cli).to receive(:current_session).and_return(nil)
|
107
|
+
end
|
108
|
+
|
109
|
+
it "displays error and exits" do
|
110
|
+
expect { cli.refresh }.to raise_error(SystemExit) do |error|
|
111
|
+
expect(error.status).to eq(1)
|
112
|
+
end
|
113
|
+
expect {
|
114
|
+
begin
|
115
|
+
cli.refresh
|
116
|
+
rescue SystemExit
|
117
|
+
end
|
118
|
+
}.to output(/No active session to refresh/).to_stderr
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
context "when authenticated without remember token" do
|
123
|
+
before do
|
124
|
+
allow(cli).to receive(:current_session).and_return(session)
|
125
|
+
allow(session).to receive(:remember_token).and_return(nil)
|
126
|
+
end
|
127
|
+
|
128
|
+
it "displays error about missing remember token" do
|
129
|
+
expect { cli.refresh }.to raise_error(SystemExit) do |error|
|
130
|
+
expect(error.status).to eq(1)
|
131
|
+
end
|
132
|
+
expect {
|
133
|
+
begin
|
134
|
+
cli.refresh
|
135
|
+
rescue SystemExit
|
136
|
+
end
|
137
|
+
}.to output(/No remember token available/).to_stderr
|
138
|
+
end
|
139
|
+
|
140
|
+
it "suggests using --remember flag" do
|
141
|
+
expect {
|
142
|
+
begin
|
143
|
+
cli.refresh
|
144
|
+
rescue SystemExit
|
145
|
+
end
|
146
|
+
}.to output(/Login with --remember flag/).to_stdout
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
context "when authenticated with remember token" do
|
151
|
+
let(:manager) { instance_double(Tastytrade::SessionManager) }
|
152
|
+
|
153
|
+
before do
|
154
|
+
allow(cli).to receive(:current_session).and_return(session)
|
155
|
+
allow(session).to receive(:remember_token).and_return("token123")
|
156
|
+
allow(session).to receive(:user).and_return(user)
|
157
|
+
allow(Tastytrade::SessionManager).to receive(:new).and_return(manager)
|
158
|
+
end
|
159
|
+
|
160
|
+
context "on successful refresh" do
|
161
|
+
before do
|
162
|
+
allow(session).to receive(:refresh_session).and_return(session)
|
163
|
+
allow(session).to receive(:time_until_expiry).and_return(3600)
|
164
|
+
allow(manager).to receive(:save_session).and_return(true)
|
165
|
+
end
|
166
|
+
|
167
|
+
it "refreshes the session" do
|
168
|
+
expect(session).to receive(:refresh_session)
|
169
|
+
cli.refresh
|
170
|
+
end
|
171
|
+
|
172
|
+
it "saves the refreshed session" do
|
173
|
+
expect(manager).to receive(:save_session).with(session)
|
174
|
+
cli.refresh
|
175
|
+
end
|
176
|
+
|
177
|
+
it "displays success message" do
|
178
|
+
expect { cli.refresh }.to output(/Session refreshed successfully/).to_stdout
|
179
|
+
end
|
180
|
+
|
181
|
+
it "displays new expiration time" do
|
182
|
+
expect { cli.refresh }.to output(/Session expires in 1h 0m/).to_stdout
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
context "on refresh failure" do
|
187
|
+
before do
|
188
|
+
allow(session).to receive(:refresh_session)
|
189
|
+
.and_raise(Tastytrade::TokenRefreshError, "Invalid token")
|
190
|
+
end
|
191
|
+
|
192
|
+
it "displays error and exits" do
|
193
|
+
expect { cli.refresh }.to raise_error(SystemExit) do |error|
|
194
|
+
expect(error.status).to eq(1)
|
195
|
+
end
|
196
|
+
expect {
|
197
|
+
begin
|
198
|
+
cli.refresh
|
199
|
+
rescue SystemExit
|
200
|
+
end
|
201
|
+
}.to output(/Failed to refresh session: Invalid token/).to_stderr
|
202
|
+
end
|
203
|
+
end
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|
@@ -0,0 +1,210 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "spec_helper"
|
4
|
+
|
5
|
+
RSpec.describe Tastytrade::Client do
|
6
|
+
let(:base_url) { "https://api.example.com" }
|
7
|
+
let(:client) { described_class.new(base_url: base_url) }
|
8
|
+
|
9
|
+
describe "#initialize" do
|
10
|
+
it "sets the base URL" do
|
11
|
+
expect(client.base_url).to eq(base_url)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
describe "HTTP methods" do
|
16
|
+
let(:path) { "/test" }
|
17
|
+
let(:response_body) { '{"key": "value"}' }
|
18
|
+
let(:parsed_response) { { "key" => "value" } }
|
19
|
+
|
20
|
+
describe "#get" do
|
21
|
+
it "makes a GET request and returns parsed JSON" do
|
22
|
+
stub_request(:get, "#{base_url}#{path}")
|
23
|
+
.to_return(status: 200, body: response_body, headers: { "Content-Type" => "application/json" })
|
24
|
+
|
25
|
+
result = client.get(path)
|
26
|
+
expect(result).to eq(parsed_response)
|
27
|
+
end
|
28
|
+
|
29
|
+
it "includes query parameters" do
|
30
|
+
params = { foo: "bar" }
|
31
|
+
stub_request(:get, "#{base_url}#{path}")
|
32
|
+
.with(query: params)
|
33
|
+
.to_return(status: 200, body: response_body)
|
34
|
+
|
35
|
+
client.get(path, params)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
describe "#post" do
|
40
|
+
it "makes a POST request with JSON body" do
|
41
|
+
body = { data: "test" }
|
42
|
+
stub_request(:post, "#{base_url}#{path}")
|
43
|
+
.with(body: body.to_json,
|
44
|
+
headers: { "Content-Type" => "application/json" })
|
45
|
+
.to_return(status: 201, body: response_body)
|
46
|
+
|
47
|
+
result = client.post(path, body)
|
48
|
+
expect(result).to eq(parsed_response)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
describe "#put" do
|
53
|
+
it "makes a PUT request with JSON body" do
|
54
|
+
body = { data: "updated" }
|
55
|
+
stub_request(:put, "#{base_url}#{path}")
|
56
|
+
.with(body: body.to_json,
|
57
|
+
headers: { "Content-Type" => "application/json" })
|
58
|
+
.to_return(status: 200, body: response_body)
|
59
|
+
|
60
|
+
result = client.put(path, body)
|
61
|
+
expect(result).to eq(parsed_response)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
describe "#delete" do
|
66
|
+
it "makes a DELETE request" do
|
67
|
+
stub_request(:delete, "#{base_url}#{path}")
|
68
|
+
.to_return(status: 204, body: "")
|
69
|
+
|
70
|
+
result = client.delete(path)
|
71
|
+
expect(result).to be_nil
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
describe "error handling" do
|
77
|
+
let(:path) { "/test" }
|
78
|
+
|
79
|
+
it "raises InvalidCredentialsError for 401 response" do
|
80
|
+
stub_request(:get, "#{base_url}#{path}")
|
81
|
+
.to_return(status: 401, body: '{"error": "Unauthorized"}')
|
82
|
+
|
83
|
+
expect { client.get(path) }.to raise_error(Tastytrade::InvalidCredentialsError, /Authentication failed/)
|
84
|
+
end
|
85
|
+
|
86
|
+
it "raises SessionExpiredError for 403 response" do
|
87
|
+
stub_request(:get, "#{base_url}#{path}")
|
88
|
+
.to_return(status: 403, body: '{"error": "Session expired"}')
|
89
|
+
|
90
|
+
expect { client.get(path) }.to raise_error(Tastytrade::SessionExpiredError, /Session expired or invalid/)
|
91
|
+
end
|
92
|
+
|
93
|
+
it "raises error for 404 response" do
|
94
|
+
stub_request(:get, "#{base_url}#{path}")
|
95
|
+
.to_return(status: 404, body: '{"error": "Not found"}')
|
96
|
+
|
97
|
+
expect { client.get(path) }.to raise_error(Tastytrade::Error, /Resource not found/)
|
98
|
+
end
|
99
|
+
|
100
|
+
it "raises error for 500 response" do
|
101
|
+
stub_request(:get, "#{base_url}#{path}")
|
102
|
+
.to_return(status: 500, body: '{"error": "Internal server error"}')
|
103
|
+
|
104
|
+
expect { client.get(path) }.to raise_error(Tastytrade::Error, /Server error/)
|
105
|
+
end
|
106
|
+
|
107
|
+
it "handles invalid JSON response" do
|
108
|
+
stub_request(:get, "#{base_url}#{path}")
|
109
|
+
.to_return(status: 200, body: "invalid json")
|
110
|
+
|
111
|
+
expect { client.get(path) }.to raise_error(Tastytrade::Error, /Invalid JSON response/)
|
112
|
+
end
|
113
|
+
|
114
|
+
it "handles empty response body" do
|
115
|
+
stub_request(:get, "#{base_url}#{path}")
|
116
|
+
.to_return(status: 200, body: "")
|
117
|
+
|
118
|
+
result = client.get(path)
|
119
|
+
expect(result).to be_nil
|
120
|
+
end
|
121
|
+
|
122
|
+
context "with different error message formats" do
|
123
|
+
it "handles 'error' field" do
|
124
|
+
stub_request(:get, "#{base_url}#{path}")
|
125
|
+
.to_return(status: 400, body: '{"error": "Bad request"}')
|
126
|
+
|
127
|
+
expect { client.get(path) }.to raise_error(Tastytrade::Error, /Bad request/)
|
128
|
+
end
|
129
|
+
|
130
|
+
it "handles 'message' field" do
|
131
|
+
stub_request(:get, "#{base_url}#{path}")
|
132
|
+
.to_return(status: 400, body: '{"message": "Invalid input"}')
|
133
|
+
|
134
|
+
expect { client.get(path) }.to raise_error(Tastytrade::Error, /Invalid input/)
|
135
|
+
end
|
136
|
+
|
137
|
+
it "handles 'reason' field" do
|
138
|
+
stub_request(:get, "#{base_url}#{path}")
|
139
|
+
.to_return(status: 400, body: '{"reason": "Missing parameter"}')
|
140
|
+
|
141
|
+
expect { client.get(path) }.to raise_error(Tastytrade::Error, /Missing parameter/)
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
describe "retry behavior" do
|
147
|
+
let(:path) { "/test" }
|
148
|
+
|
149
|
+
it "retries on 503 errors" do
|
150
|
+
stub_request(:get, "#{base_url}#{path}")
|
151
|
+
.to_return(status: 503, body: "")
|
152
|
+
.then.to_return(status: 200, body: '{"success": true}')
|
153
|
+
|
154
|
+
result = client.get(path)
|
155
|
+
expect(result).to eq({ "success" => true })
|
156
|
+
end
|
157
|
+
|
158
|
+
it "does not retry on POST requests" do
|
159
|
+
# POST is not in the retry methods list by default
|
160
|
+
stub_request(:post, "#{base_url}#{path}")
|
161
|
+
.to_return(status: 503, body: "")
|
162
|
+
.times(1)
|
163
|
+
|
164
|
+
expect { client.post(path) }.to raise_error(Tastytrade::Error, /Server error/)
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
describe "timeout handling" do
|
169
|
+
let(:path) { "/test" }
|
170
|
+
|
171
|
+
it "raises NetworkTimeoutError on GET timeout" do
|
172
|
+
stub_request(:get, "#{base_url}#{path}")
|
173
|
+
.to_timeout
|
174
|
+
|
175
|
+
expect { client.get(path) }.to raise_error(Tastytrade::NetworkTimeoutError, /Request timed out/)
|
176
|
+
end
|
177
|
+
|
178
|
+
it "raises NetworkTimeoutError on POST timeout" do
|
179
|
+
stub_request(:post, "#{base_url}#{path}")
|
180
|
+
.to_timeout
|
181
|
+
|
182
|
+
expect { client.post(path) }.to raise_error(Tastytrade::NetworkTimeoutError, /Request timed out/)
|
183
|
+
end
|
184
|
+
|
185
|
+
it "raises NetworkTimeoutError on PUT timeout" do
|
186
|
+
stub_request(:put, "#{base_url}#{path}")
|
187
|
+
.to_timeout
|
188
|
+
|
189
|
+
expect { client.put(path) }.to raise_error(Tastytrade::NetworkTimeoutError, /Request timed out/)
|
190
|
+
end
|
191
|
+
|
192
|
+
it "raises NetworkTimeoutError on DELETE timeout" do
|
193
|
+
stub_request(:delete, "#{base_url}#{path}")
|
194
|
+
.to_timeout
|
195
|
+
|
196
|
+
expect { client.delete(path) }.to raise_error(Tastytrade::NetworkTimeoutError, /Request timed out/)
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
describe "timeout configuration" do
|
201
|
+
it "accepts custom timeout" do
|
202
|
+
custom_client = described_class.new(base_url: base_url, timeout: 60)
|
203
|
+
expect(custom_client.instance_variable_get(:@timeout)).to eq(60)
|
204
|
+
end
|
205
|
+
|
206
|
+
it "uses default timeout when not specified" do
|
207
|
+
expect(client.instance_variable_get(:@timeout)).to eq(Tastytrade::Client::DEFAULT_TIMEOUT)
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
@@ -0,0 +1,168 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "spec_helper"
|
4
|
+
require "tastytrade/keyring_store"
|
5
|
+
|
6
|
+
RSpec.describe Tastytrade::KeyringStore do
|
7
|
+
let(:mock_backend) { instance_double(Keyring) }
|
8
|
+
|
9
|
+
before do
|
10
|
+
# Reset the backend before each test
|
11
|
+
described_class.instance_variable_set(:@backend, nil)
|
12
|
+
allow(Keyring).to receive(:new).and_return(mock_backend)
|
13
|
+
# Suppress warnings during tests
|
14
|
+
allow(described_class).to receive(:warn)
|
15
|
+
end
|
16
|
+
|
17
|
+
describe ".set" do
|
18
|
+
it "stores a credential successfully" do
|
19
|
+
expect(mock_backend).to receive(:set_password)
|
20
|
+
.with("tastytrade-ruby", "test_key", "test_value")
|
21
|
+
|
22
|
+
expect(described_class.set("test_key", "test_value")).to be true
|
23
|
+
end
|
24
|
+
|
25
|
+
it "returns false for nil key" do
|
26
|
+
expect(mock_backend).not_to receive(:set_password)
|
27
|
+
expect(described_class.set(nil, "value")).to be false
|
28
|
+
end
|
29
|
+
|
30
|
+
it "returns false for nil value" do
|
31
|
+
expect(mock_backend).not_to receive(:set_password)
|
32
|
+
expect(described_class.set("key", nil)).to be false
|
33
|
+
end
|
34
|
+
|
35
|
+
it "handles errors gracefully" do
|
36
|
+
expect(mock_backend).to receive(:set_password)
|
37
|
+
.and_raise(StandardError, "Keyring error")
|
38
|
+
|
39
|
+
expect(described_class.set("key", "value")).to be false
|
40
|
+
end
|
41
|
+
|
42
|
+
it "converts symbols to strings" do
|
43
|
+
expect(mock_backend).to receive(:set_password)
|
44
|
+
.with("tastytrade-ruby", "symbol_key", "value")
|
45
|
+
|
46
|
+
described_class.set(:symbol_key, "value")
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
describe ".get" do
|
51
|
+
it "retrieves a credential successfully" do
|
52
|
+
expect(mock_backend).to receive(:get_password)
|
53
|
+
.with("tastytrade-ruby", "test_key")
|
54
|
+
.and_return("test_value")
|
55
|
+
|
56
|
+
expect(described_class.get("test_key")).to eq("test_value")
|
57
|
+
end
|
58
|
+
|
59
|
+
it "returns nil for nil key" do
|
60
|
+
expect(mock_backend).not_to receive(:get_password)
|
61
|
+
expect(described_class.get(nil)).to be_nil
|
62
|
+
end
|
63
|
+
|
64
|
+
it "handles errors gracefully" do
|
65
|
+
expect(mock_backend).to receive(:get_password)
|
66
|
+
.and_raise(StandardError, "Keyring error")
|
67
|
+
|
68
|
+
expect(described_class.get("key")).to be_nil
|
69
|
+
end
|
70
|
+
|
71
|
+
it "converts symbols to strings" do
|
72
|
+
expect(mock_backend).to receive(:get_password)
|
73
|
+
.with("tastytrade-ruby", "symbol_key")
|
74
|
+
.and_return("value")
|
75
|
+
|
76
|
+
expect(described_class.get(:symbol_key)).to eq("value")
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
describe ".delete" do
|
81
|
+
it "deletes a credential successfully" do
|
82
|
+
expect(mock_backend).to receive(:delete_password)
|
83
|
+
.with("tastytrade-ruby", "test_key")
|
84
|
+
|
85
|
+
expect(described_class.delete("test_key")).to be true
|
86
|
+
end
|
87
|
+
|
88
|
+
it "returns false for nil key" do
|
89
|
+
expect(mock_backend).not_to receive(:delete_password)
|
90
|
+
expect(described_class.delete(nil)).to be false
|
91
|
+
end
|
92
|
+
|
93
|
+
it "handles errors gracefully" do
|
94
|
+
expect(mock_backend).to receive(:delete_password)
|
95
|
+
.and_raise(StandardError, "Keyring error")
|
96
|
+
|
97
|
+
expect(described_class.delete("key")).to be false
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
describe ".available?" do
|
102
|
+
context "when keyring is available" do
|
103
|
+
it "returns true" do
|
104
|
+
expect(described_class.available?).to be true
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
context "when keyring is not available" do
|
109
|
+
before do
|
110
|
+
allow(Keyring).to receive(:new).and_raise(StandardError, "No backend")
|
111
|
+
end
|
112
|
+
|
113
|
+
it "returns false" do
|
114
|
+
expect(described_class.available?).to be false
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
context "when backend is nil" do
|
119
|
+
before do
|
120
|
+
allow(Keyring).to receive(:new).and_return(nil)
|
121
|
+
end
|
122
|
+
|
123
|
+
it "returns false" do
|
124
|
+
expect(described_class.available?).to be false
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
describe "error messages" do
|
130
|
+
context "when warnings are not suppressed" do
|
131
|
+
before do
|
132
|
+
allow(described_class).to receive(:warn).and_call_original
|
133
|
+
end
|
134
|
+
|
135
|
+
it "warns on set failure" do
|
136
|
+
expect(mock_backend).to receive(:set_password)
|
137
|
+
.and_raise(StandardError, "Set error")
|
138
|
+
|
139
|
+
expect { described_class.set("key", "value") }
|
140
|
+
.to output(/Failed to store credential: Set error/).to_stderr
|
141
|
+
end
|
142
|
+
|
143
|
+
it "warns on get failure" do
|
144
|
+
expect(mock_backend).to receive(:get_password)
|
145
|
+
.and_raise(StandardError, "Get error")
|
146
|
+
|
147
|
+
expect { described_class.get("key") }
|
148
|
+
.to output(/Failed to retrieve credential: Get error/).to_stderr
|
149
|
+
end
|
150
|
+
|
151
|
+
it "warns on delete failure" do
|
152
|
+
expect(mock_backend).to receive(:delete_password)
|
153
|
+
.and_raise(StandardError, "Delete error")
|
154
|
+
|
155
|
+
expect { described_class.delete("key") }
|
156
|
+
.to output(/Failed to delete credential: Delete error/).to_stderr
|
157
|
+
end
|
158
|
+
|
159
|
+
it "warns when keyring is not available" do
|
160
|
+
allow(Keyring).to receive(:new).and_raise(StandardError, "No backend")
|
161
|
+
|
162
|
+
# Force backend initialization
|
163
|
+
expect { described_class.send(:backend) }
|
164
|
+
.to output(/Keyring not available: No backend/).to_stderr
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|