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,166 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "spec_helper"
|
4
|
+
require "tastytrade/cli"
|
5
|
+
require "tty-table"
|
6
|
+
|
7
|
+
RSpec.describe "Tastytrade::CLI accounts command" do
|
8
|
+
let(:cli) { Tastytrade::CLI.new }
|
9
|
+
let(:config) { instance_double(Tastytrade::CLIConfig) }
|
10
|
+
let(:session) { instance_double(Tastytrade::Session) }
|
11
|
+
let(:account1) do
|
12
|
+
instance_double(Tastytrade::Models::Account,
|
13
|
+
account_number: "5WX12345",
|
14
|
+
nickname: "Main Account",
|
15
|
+
account_type_name: "Margin")
|
16
|
+
end
|
17
|
+
let(:account2) do
|
18
|
+
instance_double(Tastytrade::Models::Account,
|
19
|
+
account_number: "5WX67890",
|
20
|
+
nickname: nil,
|
21
|
+
account_type_name: "Cash")
|
22
|
+
end
|
23
|
+
|
24
|
+
before do
|
25
|
+
allow(cli).to receive(:config).and_return(config)
|
26
|
+
allow(cli).to receive(:exit) # Prevent actual exit during tests
|
27
|
+
allow(cli).to receive(:current_session).and_return(session)
|
28
|
+
allow(cli).to receive(:authenticated?).and_return(true)
|
29
|
+
allow(config).to receive(:get).with("current_account_number").and_return(nil)
|
30
|
+
allow(config).to receive(:set)
|
31
|
+
end
|
32
|
+
|
33
|
+
describe "#accounts" do
|
34
|
+
context "when not authenticated" do
|
35
|
+
before do
|
36
|
+
allow(cli).to receive(:authenticated?).and_return(false)
|
37
|
+
end
|
38
|
+
|
39
|
+
it "requires authentication" do
|
40
|
+
expect(cli).to receive(:require_authentication!)
|
41
|
+
# Mock the session.get call that happens after authentication check
|
42
|
+
allow(session).to receive(:get).with("/customers/me/accounts/", {}).and_return(
|
43
|
+
{ "data" => { "items" => [] } }
|
44
|
+
)
|
45
|
+
cli.accounts
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
context "with no accounts" do
|
50
|
+
before do
|
51
|
+
allow(Tastytrade::Models::Account).to receive(:get_all).with(session).and_return([])
|
52
|
+
end
|
53
|
+
|
54
|
+
it "displays warning message" do
|
55
|
+
expect { cli.accounts }.to output(/No accounts found/).to_stderr
|
56
|
+
end
|
57
|
+
|
58
|
+
it "displays fetching message" do
|
59
|
+
expect { cli.accounts }.to output(/Fetching accounts/).to_stdout
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
context "with single account" do
|
64
|
+
before do
|
65
|
+
allow(Tastytrade::Models::Account).to receive(:get_all).with(session).and_return([account1])
|
66
|
+
end
|
67
|
+
|
68
|
+
it "displays account in table format" do
|
69
|
+
expect { cli.accounts }.to output(/5WX12345/).to_stdout
|
70
|
+
expect { cli.accounts }.to output(/Main Account/).to_stdout
|
71
|
+
expect { cli.accounts }.to output(/Margin/).to_stdout
|
72
|
+
end
|
73
|
+
|
74
|
+
it "auto-selects the account" do
|
75
|
+
expect(config).to receive(:set).with("current_account_number", "5WX12345")
|
76
|
+
cli.accounts
|
77
|
+
end
|
78
|
+
|
79
|
+
it "shows using account message" do
|
80
|
+
expect { cli.accounts }.to output(/Using account: 5WX12345/).to_stdout
|
81
|
+
end
|
82
|
+
|
83
|
+
it "shows total accounts" do
|
84
|
+
expect { cli.accounts }.to output(/Total accounts: 1/).to_stdout
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
context "with multiple accounts" do
|
89
|
+
before do
|
90
|
+
allow(Tastytrade::Models::Account).to receive(:get_all).with(session).and_return([account1, account2])
|
91
|
+
end
|
92
|
+
|
93
|
+
it "displays all accounts in table format" do
|
94
|
+
expect { cli.accounts }.to output(/5WX12345.*Main Account.*Margin/).to_stdout
|
95
|
+
expect { cli.accounts }.to output(/5WX67890.*Cash/).to_stdout
|
96
|
+
end
|
97
|
+
|
98
|
+
it "handles nil nickname" do
|
99
|
+
expect { cli.accounts }.to output(/5WX67890.*-.*Cash/).to_stdout
|
100
|
+
end
|
101
|
+
|
102
|
+
it "prompts to select account" do
|
103
|
+
expect { cli.accounts }.to output(/Use 'tastytrade select' to choose an account/).to_stdout
|
104
|
+
end
|
105
|
+
|
106
|
+
it "shows total accounts" do
|
107
|
+
expect { cli.accounts }.to output(/Total accounts: 2/).to_stdout
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
context "with current account selected" do
|
112
|
+
before do
|
113
|
+
allow(config).to receive(:get).with("current_account_number").and_return("5WX12345")
|
114
|
+
allow(Tastytrade::Models::Account).to receive(:get_all).with(session).and_return([account1, account2])
|
115
|
+
end
|
116
|
+
|
117
|
+
it "shows indicator for current account" do
|
118
|
+
expect { cli.accounts }.to output(/→.*5WX12345/).to_stdout
|
119
|
+
end
|
120
|
+
|
121
|
+
it "doesn't prompt to select account" do
|
122
|
+
expect { cli.accounts }.not_to output(/Use 'tastytrade select'/).to_stdout
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
context "with invalid current account" do
|
127
|
+
before do
|
128
|
+
allow(config).to receive(:get).with("current_account_number").and_return("INVALID")
|
129
|
+
allow(Tastytrade::Models::Account).to receive(:get_all).with(session).and_return([account1, account2])
|
130
|
+
end
|
131
|
+
|
132
|
+
it "prompts to select account" do
|
133
|
+
expect { cli.accounts }.to output(/Use 'tastytrade select' to choose an account/).to_stdout
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
context "on API error" do
|
138
|
+
before do
|
139
|
+
allow(Tastytrade::Models::Account).to receive(:get_all).with(session).and_raise(Tastytrade::Error,
|
140
|
+
"Network error")
|
141
|
+
end
|
142
|
+
|
143
|
+
it "displays error message" do
|
144
|
+
expect(cli).to receive(:exit).with(1)
|
145
|
+
expect { cli.accounts }.to output(/Failed to fetch accounts: Network error/).to_stderr
|
146
|
+
end
|
147
|
+
|
148
|
+
it "exits with status 1" do
|
149
|
+
expect(cli).to receive(:exit).with(1)
|
150
|
+
cli.accounts
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
context "on unexpected error" do
|
155
|
+
before do
|
156
|
+
allow(Tastytrade::Models::Account).to receive(:get_all).with(session).and_raise(StandardError,
|
157
|
+
"Unexpected issue")
|
158
|
+
end
|
159
|
+
|
160
|
+
it "displays generic error message" do
|
161
|
+
expect(cli).to receive(:exit).with(1)
|
162
|
+
expect { cli.accounts }.to output(/Unexpected error: Unexpected issue/).to_stderr
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
@@ -0,0 +1,216 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "spec_helper"
|
4
|
+
require "tastytrade/cli"
|
5
|
+
|
6
|
+
RSpec.describe "Tastytrade::CLI authentication commands" do
|
7
|
+
let(:cli) { Tastytrade::CLI.new }
|
8
|
+
let(:session) { instance_double(Tastytrade::Session) }
|
9
|
+
let(:user) { instance_double(Tastytrade::Models::User, email: "test@example.com") }
|
10
|
+
let(:prompt) { instance_double(TTY::Prompt) }
|
11
|
+
let(:config) { instance_double(Tastytrade::CLIConfig) }
|
12
|
+
let(:session_manager) { instance_double(Tastytrade::SessionManager) }
|
13
|
+
|
14
|
+
before do
|
15
|
+
allow(cli).to receive(:prompt).and_return(prompt)
|
16
|
+
allow(cli).to receive(:config).and_return(config)
|
17
|
+
allow(cli).to receive(:exit) # Prevent actual exit during tests
|
18
|
+
allow(cli).to receive(:interactive_mode) # Mock interactive mode
|
19
|
+
allow(Tastytrade::SessionManager).to receive(:new).and_return(session_manager)
|
20
|
+
allow(session_manager).to receive(:save_session).and_return(true)
|
21
|
+
end
|
22
|
+
|
23
|
+
describe "#login" do
|
24
|
+
context "with username provided via option" do
|
25
|
+
before do
|
26
|
+
allow(prompt).to receive(:mask).with("Password:").and_return("secret123")
|
27
|
+
allow(Tastytrade::Session).to receive(:new).and_return(session)
|
28
|
+
allow(session).to receive(:login).and_return(session)
|
29
|
+
allow(session).to receive(:user).and_return(user)
|
30
|
+
allow(session).to receive(:session_token).and_return("test_session_token")
|
31
|
+
allow(session).to receive(:remember_token).and_return("test_remember_token")
|
32
|
+
allow(config).to receive(:set)
|
33
|
+
end
|
34
|
+
|
35
|
+
it "uses provided username and prompts for password" do
|
36
|
+
expect(prompt).not_to receive(:ask)
|
37
|
+
expect(prompt).to receive(:mask).with("Password:")
|
38
|
+
|
39
|
+
cli.options = { username: "test@example.com", test: false, remember: false }
|
40
|
+
expect { cli.login }.to output(/Successfully logged in/).to_stdout
|
41
|
+
end
|
42
|
+
|
43
|
+
it "creates session with correct parameters" do
|
44
|
+
expect(Tastytrade::Session).to receive(:new).with(
|
45
|
+
username: "test@example.com",
|
46
|
+
password: "secret123",
|
47
|
+
remember_me: false,
|
48
|
+
is_test: false
|
49
|
+
).and_return(session)
|
50
|
+
|
51
|
+
cli.options = { username: "test@example.com", test: false, remember: false }
|
52
|
+
cli.login
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
context "with interactive username prompt" do
|
57
|
+
before do
|
58
|
+
allow(prompt).to receive(:ask).with("Username:").and_return("test@example.com")
|
59
|
+
allow(prompt).to receive(:mask).with("Password:").and_return("secret123")
|
60
|
+
allow(Tastytrade::Session).to receive(:new).and_return(session)
|
61
|
+
allow(session).to receive(:login).and_return(session)
|
62
|
+
allow(session).to receive(:user).and_return(user)
|
63
|
+
allow(session).to receive(:session_token).and_return("test_session_token")
|
64
|
+
allow(session).to receive(:remember_token).and_return("test_remember_token")
|
65
|
+
allow(config).to receive(:set)
|
66
|
+
end
|
67
|
+
|
68
|
+
it "prompts for both username and password" do
|
69
|
+
expect(prompt).to receive(:ask).with("Username:")
|
70
|
+
expect(prompt).to receive(:mask).with("Password:")
|
71
|
+
|
72
|
+
cli.options = { test: false, remember: false }
|
73
|
+
cli.login
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
context "with test environment" do
|
78
|
+
before do
|
79
|
+
allow(prompt).to receive(:ask).with("Username:").and_return("test@example.com")
|
80
|
+
allow(prompt).to receive(:mask).with("Password:").and_return("secret123")
|
81
|
+
allow(Tastytrade::Session).to receive(:new).and_return(session)
|
82
|
+
allow(session).to receive(:login).and_return(session)
|
83
|
+
allow(session).to receive(:user).and_return(user)
|
84
|
+
allow(session).to receive(:session_token).and_return("test_session_token")
|
85
|
+
allow(session).to receive(:remember_token).and_return("test_remember_token")
|
86
|
+
allow(config).to receive(:set)
|
87
|
+
end
|
88
|
+
|
89
|
+
it "creates session with test flag" do
|
90
|
+
expect(Tastytrade::Session).to receive(:new).with(
|
91
|
+
username: "test@example.com",
|
92
|
+
password: "secret123",
|
93
|
+
remember_me: false,
|
94
|
+
is_test: true
|
95
|
+
).and_return(session)
|
96
|
+
|
97
|
+
cli.options = { test: true, remember: false }
|
98
|
+
expect { cli.login }.to output(/sandbox environment/).to_stdout
|
99
|
+
end
|
100
|
+
|
101
|
+
it "saves sandbox environment to config" do
|
102
|
+
# The config is set inside SessionManager#save_session
|
103
|
+
expect(Tastytrade::SessionManager).to receive(:new).with(
|
104
|
+
username: "test@example.com",
|
105
|
+
environment: "sandbox"
|
106
|
+
).and_return(session_manager)
|
107
|
+
|
108
|
+
cli.options = { test: true, remember: false }
|
109
|
+
cli.login
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
context "with remember option" do
|
114
|
+
before do
|
115
|
+
allow(prompt).to receive(:ask).with("Username:").and_return("test@example.com")
|
116
|
+
allow(prompt).to receive(:mask).with("Password:").and_return("secret123")
|
117
|
+
allow(Tastytrade::Session).to receive(:new).and_return(session)
|
118
|
+
allow(session).to receive(:login).and_return(session)
|
119
|
+
allow(session).to receive(:user).and_return(user)
|
120
|
+
allow(session).to receive(:session_token).and_return("test_session_token")
|
121
|
+
allow(session).to receive(:remember_token).and_return("test_remember_token")
|
122
|
+
allow(config).to receive(:set)
|
123
|
+
end
|
124
|
+
|
125
|
+
it "creates session with remember flag" do
|
126
|
+
expect(Tastytrade::Session).to receive(:new).with(
|
127
|
+
username: "test@example.com",
|
128
|
+
password: "secret123",
|
129
|
+
remember_me: true,
|
130
|
+
is_test: false
|
131
|
+
).and_return(session)
|
132
|
+
|
133
|
+
cli.options = { test: false, remember: true }
|
134
|
+
cli.login
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
context "on successful login" do
|
139
|
+
before do
|
140
|
+
allow(prompt).to receive(:ask).with("Username:").and_return("test@example.com")
|
141
|
+
allow(prompt).to receive(:mask).with("Password:").and_return("secret123")
|
142
|
+
allow(Tastytrade::Session).to receive(:new).and_return(session)
|
143
|
+
allow(session).to receive(:login).and_return(session)
|
144
|
+
allow(session).to receive(:user).and_return(user)
|
145
|
+
allow(session).to receive(:session_token).and_return("test_session_token")
|
146
|
+
allow(session).to receive(:remember_token).and_return("test_remember_token")
|
147
|
+
end
|
148
|
+
|
149
|
+
it "saves username to config" do
|
150
|
+
# The config is set inside SessionManager#save_session
|
151
|
+
expect(Tastytrade::SessionManager).to receive(:new).with(
|
152
|
+
username: "test@example.com",
|
153
|
+
environment: "production"
|
154
|
+
).and_return(session_manager)
|
155
|
+
expect(session_manager).to receive(:save_session).with(
|
156
|
+
session,
|
157
|
+
password: "secret123",
|
158
|
+
remember: false
|
159
|
+
).and_return(true)
|
160
|
+
|
161
|
+
cli.options = { test: false, remember: false }
|
162
|
+
cli.login
|
163
|
+
end
|
164
|
+
|
165
|
+
it "displays success message with user email" do
|
166
|
+
allow(config).to receive(:set)
|
167
|
+
|
168
|
+
cli.options = { test: false, remember: false }
|
169
|
+
expect { cli.login }.to output(/Successfully logged in as test@example.com/).to_stdout
|
170
|
+
end
|
171
|
+
|
172
|
+
it "enters interactive mode after successful login" do
|
173
|
+
allow(config).to receive(:set)
|
174
|
+
expect(cli).to receive(:interactive_mode)
|
175
|
+
|
176
|
+
cli.options = { test: false, remember: false }
|
177
|
+
cli.login
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
context "on authentication error" do
|
182
|
+
before do
|
183
|
+
allow(prompt).to receive(:ask).with("Username:").and_return("test@example.com")
|
184
|
+
allow(prompt).to receive(:mask).with("Password:").and_return("wrong")
|
185
|
+
allow(Tastytrade::Session).to receive(:new).and_return(session)
|
186
|
+
allow(session).to receive(:login).and_raise(Tastytrade::Error, "Invalid credentials")
|
187
|
+
end
|
188
|
+
|
189
|
+
it "displays error message" do
|
190
|
+
cli.options = { test: false, remember: false }
|
191
|
+
expect(cli).to receive(:exit).with(1)
|
192
|
+
expect { cli.login }.to output(/Error: Invalid credentials/).to_stderr
|
193
|
+
end
|
194
|
+
|
195
|
+
it "exits with status 1" do
|
196
|
+
cli.options = { test: false, remember: false }
|
197
|
+
expect(cli).to receive(:exit).with(1)
|
198
|
+
cli.login
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
context "on unexpected error" do
|
203
|
+
before do
|
204
|
+
allow(prompt).to receive(:ask).with("Username:").and_return("test@example.com")
|
205
|
+
allow(prompt).to receive(:mask).with("Password:").and_return("secret123")
|
206
|
+
allow(Tastytrade::Session).to receive(:new).and_raise(StandardError, "Connection timeout")
|
207
|
+
end
|
208
|
+
|
209
|
+
it "displays generic error message" do
|
210
|
+
cli.options = { test: false, remember: false }
|
211
|
+
expect(cli).to receive(:exit).with(1)
|
212
|
+
expect { cli.login }.to output(/Error: Login failed: Connection timeout/).to_stderr
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
216
|
+
end
|
@@ -0,0 +1,180 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "spec_helper"
|
4
|
+
require "tastytrade/cli_config"
|
5
|
+
require "tmpdir"
|
6
|
+
require "fileutils"
|
7
|
+
|
8
|
+
RSpec.describe Tastytrade::CLIConfig do
|
9
|
+
let(:temp_dir) { Dir.mktmpdir }
|
10
|
+
let(:config_dir) { File.join(temp_dir, ".config", "tastytrade") }
|
11
|
+
let(:config_file) { File.join(config_dir, "config.yml") }
|
12
|
+
|
13
|
+
before do
|
14
|
+
# Override the config directory for testing
|
15
|
+
stub_const("Tastytrade::CLIConfig::CONFIG_DIR", config_dir)
|
16
|
+
stub_const("Tastytrade::CLIConfig::CONFIG_FILE", config_file)
|
17
|
+
end
|
18
|
+
|
19
|
+
after do
|
20
|
+
FileUtils.rm_rf(temp_dir)
|
21
|
+
end
|
22
|
+
|
23
|
+
describe "#initialize" do
|
24
|
+
context "when config file doesn't exist" do
|
25
|
+
it "creates config directory" do
|
26
|
+
described_class.new
|
27
|
+
expect(Dir.exist?(config_dir)).to be true
|
28
|
+
end
|
29
|
+
|
30
|
+
it "loads default configuration" do
|
31
|
+
config = described_class.new
|
32
|
+
expect(config.data).to eq(described_class::DEFAULT_CONFIG)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
context "when config file exists" do
|
37
|
+
before do
|
38
|
+
FileUtils.mkdir_p(config_dir)
|
39
|
+
File.write(config_file, { "default_account" => "123456" }.to_yaml)
|
40
|
+
end
|
41
|
+
|
42
|
+
it "loads config from file" do
|
43
|
+
config = described_class.new
|
44
|
+
expect(config.get("default_account")).to eq("123456")
|
45
|
+
end
|
46
|
+
|
47
|
+
it "merges with default config" do
|
48
|
+
config = described_class.new
|
49
|
+
expect(config.get("environment")).to eq("production") # from defaults
|
50
|
+
expect(config.get("default_account")).to eq("123456") # from file
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
context "when config file is corrupted" do
|
55
|
+
before do
|
56
|
+
FileUtils.mkdir_p(config_dir)
|
57
|
+
File.write(config_file, "invalid yaml: [")
|
58
|
+
end
|
59
|
+
|
60
|
+
it "loads default config and warns" do
|
61
|
+
expect { described_class.new }.to output(/Warning: Failed to load config file/).to_stderr
|
62
|
+
config = described_class.new
|
63
|
+
expect(config.data).to eq(described_class::DEFAULT_CONFIG)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
describe "#get" do
|
69
|
+
let(:config) { described_class.new }
|
70
|
+
|
71
|
+
it "returns value for existing key" do
|
72
|
+
expect(config.get("environment")).to eq("production")
|
73
|
+
end
|
74
|
+
|
75
|
+
it "returns nil for non-existent key" do
|
76
|
+
expect(config.get("non_existent")).to be_nil
|
77
|
+
end
|
78
|
+
|
79
|
+
it "accepts symbol keys" do
|
80
|
+
expect(config.get(:environment)).to eq("production")
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
describe "#set" do
|
85
|
+
let(:config) { described_class.new }
|
86
|
+
|
87
|
+
it "sets a new value" do
|
88
|
+
config.set("test_key", "test_value")
|
89
|
+
expect(config.get("test_key")).to eq("test_value")
|
90
|
+
end
|
91
|
+
|
92
|
+
it "updates existing value" do
|
93
|
+
config.set("environment", "sandbox")
|
94
|
+
expect(config.get("environment")).to eq("sandbox")
|
95
|
+
end
|
96
|
+
|
97
|
+
it "saves to file" do
|
98
|
+
config.set("test_key", "test_value")
|
99
|
+
|
100
|
+
# Load a new config instance to verify persistence
|
101
|
+
new_config = described_class.new
|
102
|
+
expect(new_config.get("test_key")).to eq("test_value")
|
103
|
+
end
|
104
|
+
|
105
|
+
it "accepts symbol keys" do
|
106
|
+
config.set(:test_key, "test_value")
|
107
|
+
expect(config.get("test_key")).to eq("test_value")
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
describe "#delete" do
|
112
|
+
let(:config) { described_class.new }
|
113
|
+
|
114
|
+
before do
|
115
|
+
config.set("test_key", "test_value")
|
116
|
+
end
|
117
|
+
|
118
|
+
it "removes the key" do
|
119
|
+
config.delete("test_key")
|
120
|
+
expect(config.get("test_key")).to be_nil
|
121
|
+
end
|
122
|
+
|
123
|
+
it "saves changes to file" do
|
124
|
+
config.delete("test_key")
|
125
|
+
|
126
|
+
new_config = described_class.new
|
127
|
+
expect(new_config.get("test_key")).to be_nil
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
describe "#exists?" do
|
132
|
+
it "returns false when config doesn't exist" do
|
133
|
+
config = described_class.new
|
134
|
+
expect(config.exists?).to be false # file not created until save
|
135
|
+
end
|
136
|
+
|
137
|
+
it "returns true when config exists" do
|
138
|
+
config = described_class.new
|
139
|
+
config.set("test", "value") # This triggers save
|
140
|
+
expect(config.exists?).to be true
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
describe "#reset!" do
|
145
|
+
let(:config) { described_class.new }
|
146
|
+
|
147
|
+
before do
|
148
|
+
config.set("test_key", "test_value")
|
149
|
+
config.set("environment", "sandbox")
|
150
|
+
end
|
151
|
+
|
152
|
+
it "resets to default values" do
|
153
|
+
config.reset!
|
154
|
+
expect(config.data).to eq(described_class::DEFAULT_CONFIG)
|
155
|
+
expect(config.get("test_key")).to be_nil
|
156
|
+
expect(config.get("environment")).to eq("production")
|
157
|
+
end
|
158
|
+
|
159
|
+
it "saves defaults to file" do
|
160
|
+
config.reset!
|
161
|
+
|
162
|
+
new_config = described_class.new
|
163
|
+
expect(new_config.data).to eq(described_class::DEFAULT_CONFIG)
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
describe "error handling" do
|
168
|
+
let(:config) { described_class.new }
|
169
|
+
|
170
|
+
context "when save fails" do
|
171
|
+
before do
|
172
|
+
allow(File).to receive(:write).and_raise(StandardError.new("Permission denied"))
|
173
|
+
end
|
174
|
+
|
175
|
+
it "warns but doesn't raise" do
|
176
|
+
expect { config.set("test", "value") }.to output(/Warning: Failed to save config file/).to_stderr
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|