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,247 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "spec_helper"
|
4
|
+
require "bigdecimal"
|
5
|
+
|
6
|
+
RSpec.describe Tastytrade::Models::AccountBalance do
|
7
|
+
let(:balance_data) do
|
8
|
+
{
|
9
|
+
"account-number" => "5WX12345",
|
10
|
+
"cash-balance" => "10000.50",
|
11
|
+
"long-equity-value" => "25000.75",
|
12
|
+
"short-equity-value" => "5000.25",
|
13
|
+
"long-derivative-value" => "3000.00",
|
14
|
+
"short-derivative-value" => "1500.00",
|
15
|
+
"net-liquidating-value" => "42001.00",
|
16
|
+
"equity-buying-power" => "20000.00",
|
17
|
+
"derivative-buying-power" => "15000.00",
|
18
|
+
"day-trading-buying-power" => "40000.00",
|
19
|
+
"available-trading-funds" => "12000.00",
|
20
|
+
"margin-equity" => "42001.00",
|
21
|
+
"pending-cash" => "500.00",
|
22
|
+
"pending-margin-interest" => "25.50",
|
23
|
+
"effective-trading-funds" => "11974.50",
|
24
|
+
"updated-at" => "2024-01-15T10:30:00Z"
|
25
|
+
}
|
26
|
+
end
|
27
|
+
|
28
|
+
subject { described_class.new(balance_data) }
|
29
|
+
|
30
|
+
describe "#initialize" do
|
31
|
+
it "parses account number" do
|
32
|
+
expect(subject.account_number).to eq("5WX12345")
|
33
|
+
end
|
34
|
+
|
35
|
+
it "converts monetary values to BigDecimal" do
|
36
|
+
expect(subject.cash_balance).to be_a(BigDecimal)
|
37
|
+
expect(subject.cash_balance).to eq(BigDecimal("10000.50"))
|
38
|
+
end
|
39
|
+
|
40
|
+
it "parses all balance fields correctly" do
|
41
|
+
expect(subject.long_equity_value).to eq(BigDecimal("25000.75"))
|
42
|
+
expect(subject.short_equity_value).to eq(BigDecimal("5000.25"))
|
43
|
+
expect(subject.long_derivative_value).to eq(BigDecimal("3000.00"))
|
44
|
+
expect(subject.short_derivative_value).to eq(BigDecimal("1500.00"))
|
45
|
+
expect(subject.net_liquidating_value).to eq(BigDecimal("42001.00"))
|
46
|
+
expect(subject.equity_buying_power).to eq(BigDecimal("20000.00"))
|
47
|
+
expect(subject.derivative_buying_power).to eq(BigDecimal("15000.00"))
|
48
|
+
expect(subject.day_trading_buying_power).to eq(BigDecimal("40000.00"))
|
49
|
+
expect(subject.available_trading_funds).to eq(BigDecimal("12000.00"))
|
50
|
+
expect(subject.margin_equity).to eq(BigDecimal("42001.00"))
|
51
|
+
expect(subject.pending_cash).to eq(BigDecimal("500.00"))
|
52
|
+
expect(subject.pending_margin_interest).to eq(BigDecimal("25.50"))
|
53
|
+
expect(subject.effective_trading_funds).to eq(BigDecimal("11974.50"))
|
54
|
+
end
|
55
|
+
|
56
|
+
it "parses updated_at as Time" do
|
57
|
+
expect(subject.updated_at).to be_a(Time)
|
58
|
+
expect(subject.updated_at.iso8601).to eq("2024-01-15T10:30:00Z")
|
59
|
+
end
|
60
|
+
|
61
|
+
context "with nil values" do
|
62
|
+
let(:balance_data) do
|
63
|
+
{
|
64
|
+
"account-number" => "5WX12345",
|
65
|
+
"cash-balance" => nil,
|
66
|
+
"long-equity-value" => "",
|
67
|
+
"net-liquidating-value" => "1000.00",
|
68
|
+
"equity-buying-power" => "1000.00",
|
69
|
+
"available-trading-funds" => "1000.00"
|
70
|
+
}
|
71
|
+
end
|
72
|
+
|
73
|
+
it "converts nil and empty strings to zero" do
|
74
|
+
expect(subject.cash_balance).to eq(BigDecimal("0"))
|
75
|
+
expect(subject.long_equity_value).to eq(BigDecimal("0"))
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
context "with string numbers" do
|
80
|
+
let(:balance_data) do
|
81
|
+
{
|
82
|
+
"account-number" => "5WX12345",
|
83
|
+
"cash-balance" => "1234.567890",
|
84
|
+
"net-liquidating-value" => "1234.567890",
|
85
|
+
"equity-buying-power" => "1000.00",
|
86
|
+
"available-trading-funds" => "1000.00"
|
87
|
+
}
|
88
|
+
end
|
89
|
+
|
90
|
+
it "maintains precision with BigDecimal" do
|
91
|
+
expect(subject.cash_balance.to_s("F")).to eq("1234.56789")
|
92
|
+
expect(subject.net_liquidating_value.to_s("F")).to eq("1234.56789")
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
describe "#buying_power_usage_percentage" do
|
98
|
+
context "with normal usage" do
|
99
|
+
it "calculates the percentage correctly" do
|
100
|
+
# Used BP = 20000 - 12000 = 8000
|
101
|
+
# Percentage = 8000 / 20000 * 100 = 40%
|
102
|
+
expect(subject.buying_power_usage_percentage).to eq(BigDecimal("40.00"))
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
context "with zero equity buying power" do
|
107
|
+
let(:balance_data) do
|
108
|
+
{
|
109
|
+
"account-number" => "5WX12345",
|
110
|
+
"equity-buying-power" => "0",
|
111
|
+
"available-trading-funds" => "0"
|
112
|
+
}
|
113
|
+
end
|
114
|
+
|
115
|
+
it "returns zero" do
|
116
|
+
expect(subject.buying_power_usage_percentage).to eq(BigDecimal("0"))
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
context "with full usage" do
|
121
|
+
let(:balance_data) do
|
122
|
+
{
|
123
|
+
"account-number" => "5WX12345",
|
124
|
+
"equity-buying-power" => "10000.00",
|
125
|
+
"available-trading-funds" => "0.00"
|
126
|
+
}
|
127
|
+
end
|
128
|
+
|
129
|
+
it "returns 100%" do
|
130
|
+
expect(subject.buying_power_usage_percentage).to eq(BigDecimal("100.00"))
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
context "with high precision" do
|
135
|
+
let(:balance_data) do
|
136
|
+
{
|
137
|
+
"account-number" => "5WX12345",
|
138
|
+
"equity-buying-power" => "10000.00",
|
139
|
+
"available-trading-funds" => "3333.33"
|
140
|
+
}
|
141
|
+
end
|
142
|
+
|
143
|
+
it "rounds to 2 decimal places" do
|
144
|
+
# Used BP = 10000 - 3333.33 = 6666.67
|
145
|
+
# Percentage = 6666.67 / 10000 * 100 = 66.6667
|
146
|
+
expect(subject.buying_power_usage_percentage).to eq(BigDecimal("66.67"))
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
describe "#high_buying_power_usage?" do
|
152
|
+
context "with default threshold (80%)" do
|
153
|
+
it "returns false when usage is below 80%" do
|
154
|
+
expect(subject.high_buying_power_usage?).to be false
|
155
|
+
end
|
156
|
+
|
157
|
+
context "with high usage" do
|
158
|
+
let(:balance_data) do
|
159
|
+
{
|
160
|
+
"account-number" => "5WX12345",
|
161
|
+
"equity-buying-power" => "10000.00",
|
162
|
+
"available-trading-funds" => "1500.00"
|
163
|
+
}
|
164
|
+
end
|
165
|
+
|
166
|
+
it "returns true when usage is above 80%" do
|
167
|
+
# Usage = 85%
|
168
|
+
expect(subject.high_buying_power_usage?).to be true
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
context "with custom threshold" do
|
174
|
+
it "uses the provided threshold" do
|
175
|
+
expect(subject.high_buying_power_usage?(30)).to be true
|
176
|
+
expect(subject.high_buying_power_usage?(50)).to be false
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
describe "#total_equity_value" do
|
182
|
+
it "sums long and short equity values" do
|
183
|
+
# 25000.75 + 5000.25 = 30001.00
|
184
|
+
expect(subject.total_equity_value).to eq(BigDecimal("30001.00"))
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
describe "#total_derivative_value" do
|
189
|
+
it "sums long and short derivative values" do
|
190
|
+
# 3000.00 + 1500.00 = 4500.00
|
191
|
+
expect(subject.total_derivative_value).to eq(BigDecimal("4500.00"))
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
describe "#total_market_value" do
|
196
|
+
it "sums all market values" do
|
197
|
+
# 30001.00 + 4500.00 = 34501.00
|
198
|
+
expect(subject.total_market_value).to eq(BigDecimal("34501.00"))
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
describe "attribute readers" do
|
203
|
+
it "provides access to all balance fields" do
|
204
|
+
expect(subject).to respond_to(:account_number)
|
205
|
+
expect(subject).to respond_to(:cash_balance)
|
206
|
+
expect(subject).to respond_to(:long_equity_value)
|
207
|
+
expect(subject).to respond_to(:short_equity_value)
|
208
|
+
expect(subject).to respond_to(:long_derivative_value)
|
209
|
+
expect(subject).to respond_to(:short_derivative_value)
|
210
|
+
expect(subject).to respond_to(:net_liquidating_value)
|
211
|
+
expect(subject).to respond_to(:equity_buying_power)
|
212
|
+
expect(subject).to respond_to(:derivative_buying_power)
|
213
|
+
expect(subject).to respond_to(:day_trading_buying_power)
|
214
|
+
expect(subject).to respond_to(:available_trading_funds)
|
215
|
+
expect(subject).to respond_to(:margin_equity)
|
216
|
+
expect(subject).to respond_to(:pending_cash)
|
217
|
+
expect(subject).to respond_to(:pending_margin_interest)
|
218
|
+
expect(subject).to respond_to(:effective_trading_funds)
|
219
|
+
expect(subject).to respond_to(:updated_at)
|
220
|
+
end
|
221
|
+
end
|
222
|
+
|
223
|
+
describe "BigDecimal precision" do
|
224
|
+
let(:balance_data) do
|
225
|
+
{
|
226
|
+
"account-number" => "5WX12345",
|
227
|
+
"cash-balance" => "0.01",
|
228
|
+
"net-liquidating-value" => "999999.99",
|
229
|
+
"equity-buying-power" => "123456.789",
|
230
|
+
"available-trading-funds" => "0.001"
|
231
|
+
}
|
232
|
+
end
|
233
|
+
|
234
|
+
it "handles small values correctly" do
|
235
|
+
expect(subject.cash_balance).to eq(BigDecimal("0.01"))
|
236
|
+
expect(subject.available_trading_funds).to eq(BigDecimal("0.001"))
|
237
|
+
end
|
238
|
+
|
239
|
+
it "handles large values correctly" do
|
240
|
+
expect(subject.net_liquidating_value).to eq(BigDecimal("999999.99"))
|
241
|
+
end
|
242
|
+
|
243
|
+
it "maintains precision beyond 2 decimal places" do
|
244
|
+
expect(subject.equity_buying_power).to eq(BigDecimal("123456.789"))
|
245
|
+
end
|
246
|
+
end
|
247
|
+
end
|
@@ -0,0 +1,206 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "spec_helper"
|
4
|
+
require "bigdecimal"
|
5
|
+
|
6
|
+
RSpec.describe Tastytrade::Models::Account do
|
7
|
+
let(:account_data) do
|
8
|
+
{
|
9
|
+
"account-number" => "123456",
|
10
|
+
"nickname" => "My Account",
|
11
|
+
"account-type-name" => "Individual",
|
12
|
+
"opened-at" => "2025-01-01T10:00:00Z",
|
13
|
+
"is-closed" => false,
|
14
|
+
"day-trader-status" => false,
|
15
|
+
"is-futures-approved" => true,
|
16
|
+
"margin-or-cash" => "Margin",
|
17
|
+
"is-foreign" => false,
|
18
|
+
"created-at" => "2025-01-01T10:00:00Z",
|
19
|
+
"is-test-drive" => false
|
20
|
+
}
|
21
|
+
end
|
22
|
+
|
23
|
+
let(:session) { instance_double(Tastytrade::Session) }
|
24
|
+
subject(:account) { described_class.new(account_data) }
|
25
|
+
|
26
|
+
describe "attributes" do
|
27
|
+
it "parses account number" do
|
28
|
+
expect(account.account_number).to eq("123456")
|
29
|
+
end
|
30
|
+
|
31
|
+
it "parses nickname" do
|
32
|
+
expect(account.nickname).to eq("My Account")
|
33
|
+
end
|
34
|
+
|
35
|
+
it "parses account type" do
|
36
|
+
expect(account.account_type_name).to eq("Individual")
|
37
|
+
end
|
38
|
+
|
39
|
+
it "parses opened_at as Time" do
|
40
|
+
expect(account.opened_at).to be_a(Time)
|
41
|
+
expect(account.opened_at.year).to eq(2025)
|
42
|
+
end
|
43
|
+
|
44
|
+
it "parses boolean fields" do
|
45
|
+
expect(account.is_closed).to be false
|
46
|
+
expect(account.day_trader_status).to be false
|
47
|
+
expect(account.is_futures_approved).to be true
|
48
|
+
end
|
49
|
+
|
50
|
+
it "parses optional fields" do
|
51
|
+
expect(account.external_id).to be_nil
|
52
|
+
expect(account.closed_at).to be_nil
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
describe ".get_all" do
|
57
|
+
let(:response) do
|
58
|
+
{
|
59
|
+
"data" => {
|
60
|
+
"items" => [
|
61
|
+
{
|
62
|
+
"account" => account_data,
|
63
|
+
"authority-level" => "owner"
|
64
|
+
},
|
65
|
+
{
|
66
|
+
"account" => account_data.merge("account-number" => "789012"),
|
67
|
+
"authority-level" => "owner"
|
68
|
+
}
|
69
|
+
]
|
70
|
+
}
|
71
|
+
}
|
72
|
+
end
|
73
|
+
|
74
|
+
it "returns array of Account objects" do
|
75
|
+
allow(session).to receive(:get).with("/customers/me/accounts/", {}).and_return(response)
|
76
|
+
|
77
|
+
accounts = described_class.get_all(session)
|
78
|
+
|
79
|
+
expect(accounts).to be_an(Array)
|
80
|
+
expect(accounts.size).to eq(2)
|
81
|
+
expect(accounts.first).to be_a(described_class)
|
82
|
+
expect(accounts.first.account_number).to eq("123456")
|
83
|
+
expect(accounts.last.account_number).to eq("789012")
|
84
|
+
end
|
85
|
+
|
86
|
+
it "includes closed accounts when specified" do
|
87
|
+
allow(session).to receive(:get).with("/customers/me/accounts/", { "include-closed" => true })
|
88
|
+
.and_return(response)
|
89
|
+
|
90
|
+
described_class.get_all(session, include_closed: true)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
describe ".get" do
|
95
|
+
let(:response) do
|
96
|
+
{ "data" => account_data }
|
97
|
+
end
|
98
|
+
|
99
|
+
it "returns single Account object" do
|
100
|
+
allow(session).to receive(:get).with("/accounts/123456/").and_return(response)
|
101
|
+
|
102
|
+
account = described_class.get(session, "123456")
|
103
|
+
|
104
|
+
expect(account).to be_a(described_class)
|
105
|
+
expect(account.account_number).to eq("123456")
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
describe "#get_balances" do
|
110
|
+
let(:balance_data) do
|
111
|
+
{
|
112
|
+
"data" => {
|
113
|
+
"account-number" => "123456",
|
114
|
+
"cash-balance" => "10000.00",
|
115
|
+
"net-liquidating-value" => "15000.00"
|
116
|
+
}
|
117
|
+
}
|
118
|
+
end
|
119
|
+
|
120
|
+
it "returns balance data" do
|
121
|
+
allow(session).to receive(:get).with("/accounts/123456/balances/").and_return(balance_data)
|
122
|
+
|
123
|
+
balance = account.get_balances(session)
|
124
|
+
|
125
|
+
expect(balance).to be_a(Tastytrade::Models::AccountBalance)
|
126
|
+
expect(balance.account_number).to eq("123456")
|
127
|
+
expect(balance.cash_balance).to eq(BigDecimal("10000.00"))
|
128
|
+
expect(balance.net_liquidating_value).to eq(BigDecimal("15000.00"))
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
describe "#get_positions" do
|
133
|
+
let(:positions_data) do
|
134
|
+
{
|
135
|
+
"data" => {
|
136
|
+
"items" => [
|
137
|
+
{ "symbol" => "AAPL", "quantity" => "100" },
|
138
|
+
{ "symbol" => "MSFT", "quantity" => "50" }
|
139
|
+
]
|
140
|
+
}
|
141
|
+
}
|
142
|
+
end
|
143
|
+
|
144
|
+
it "returns array of positions" do
|
145
|
+
allow(session).to receive(:get).with("/accounts/123456/positions/", {}).and_return(positions_data)
|
146
|
+
|
147
|
+
positions = account.get_positions(session)
|
148
|
+
|
149
|
+
expect(positions).to be_an(Array)
|
150
|
+
expect(positions.size).to eq(2)
|
151
|
+
expect(positions.first).to be_a(Tastytrade::Models::CurrentPosition)
|
152
|
+
expect(positions.first.symbol).to eq("AAPL")
|
153
|
+
expect(positions.first.quantity).to eq(BigDecimal("100"))
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
describe "#get_trading_status" do
|
158
|
+
let(:status_data) do
|
159
|
+
{
|
160
|
+
"data" => {
|
161
|
+
"account-number" => "123456",
|
162
|
+
"is-pattern-day-trader" => false
|
163
|
+
}
|
164
|
+
}
|
165
|
+
end
|
166
|
+
|
167
|
+
it "returns trading status data" do
|
168
|
+
allow(session).to receive(:get).with("/accounts/123456/trading-status/").and_return(status_data)
|
169
|
+
|
170
|
+
status = account.get_trading_status(session)
|
171
|
+
|
172
|
+
expect(status).to eq(status_data["data"])
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
describe "boolean helper methods" do
|
177
|
+
describe "#closed?" do
|
178
|
+
it "returns true when is_closed is true" do
|
179
|
+
account = described_class.new(account_data.merge("is-closed" => true))
|
180
|
+
expect(account.closed?).to be true
|
181
|
+
end
|
182
|
+
|
183
|
+
it "returns false when is_closed is false" do
|
184
|
+
expect(account.closed?).to be false
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
describe "#futures_approved?" do
|
189
|
+
it "returns true when is_futures_approved is true" do
|
190
|
+
expect(account.futures_approved?).to be true
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
describe "#test_drive?" do
|
195
|
+
it "returns false when is_test_drive is false" do
|
196
|
+
expect(account.test_drive?).to be false
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
describe "#foreign?" do
|
201
|
+
it "returns false when is_foreign is false" do
|
202
|
+
expect(account.foreign?).to be false
|
203
|
+
end
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "spec_helper"
|
4
|
+
|
5
|
+
RSpec.describe Tastytrade::Models::Base do
|
6
|
+
let(:test_class) do
|
7
|
+
Class.new(described_class) do
|
8
|
+
attr_reader :test_attr, :parsed_time
|
9
|
+
|
10
|
+
private
|
11
|
+
|
12
|
+
def parse_attributes
|
13
|
+
@test_attr = @data["test-attr"]
|
14
|
+
@parsed_time = parse_time(@data["time-field"])
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
describe "#initialize" do
|
20
|
+
it "accepts hash with string keys" do
|
21
|
+
instance = test_class.new("test-attr" => "value")
|
22
|
+
expect(instance.test_attr).to eq("value")
|
23
|
+
end
|
24
|
+
|
25
|
+
it "accepts hash with symbol keys" do
|
26
|
+
instance = test_class.new(test_attr: "value")
|
27
|
+
expect(instance.data["test_attr"]).to eq("value")
|
28
|
+
end
|
29
|
+
|
30
|
+
it "stores raw data" do
|
31
|
+
data = { "test-attr" => "value", "other" => "data" }
|
32
|
+
instance = test_class.new(data)
|
33
|
+
expect(instance.data).to eq(data.transform_keys(&:to_s))
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
describe "#parse_time" do
|
38
|
+
let(:instance) { test_class.new({}) }
|
39
|
+
|
40
|
+
it "parses valid ISO 8601 datetime" do
|
41
|
+
instance = test_class.new("time-field" => "2025-07-30T10:30:00Z")
|
42
|
+
expect(instance.parsed_time).to be_a(Time)
|
43
|
+
expect(instance.parsed_time.year).to eq(2025)
|
44
|
+
end
|
45
|
+
|
46
|
+
it "returns nil for nil value" do
|
47
|
+
instance = test_class.new("time-field" => nil)
|
48
|
+
expect(instance.parsed_time).to be_nil
|
49
|
+
end
|
50
|
+
|
51
|
+
it "returns nil for empty string" do
|
52
|
+
instance = test_class.new("time-field" => "")
|
53
|
+
expect(instance.parsed_time).to be_nil
|
54
|
+
end
|
55
|
+
|
56
|
+
it "returns nil for invalid datetime" do
|
57
|
+
instance = test_class.new("time-field" => "not a date")
|
58
|
+
expect(instance.parsed_time).to be_nil
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|