amounts 0.0.1
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/.rubocop.yml +89 -0
- data/CHANGELOG.md +9 -0
- data/Gemfile +24 -0
- data/LICENSE.txt +21 -0
- data/README.md +402 -0
- data/Rakefile +18 -0
- data/bin/console +8 -0
- data/bin/setup +4 -0
- data/lib/amount/active_record/amount_validator.rb +115 -0
- data/lib/amount/active_record/attribute_definition.rb +192 -0
- data/lib/amount/active_record/migration_methods.rb +74 -0
- data/lib/amount/active_record/model.rb +140 -0
- data/lib/amount/active_record/rspec.rb +8 -0
- data/lib/amount/active_record/type.rb +106 -0
- data/lib/amount/active_record.rb +44 -0
- data/lib/amount/display.rb +82 -0
- data/lib/amount/parser.rb +50 -0
- data/lib/amount/registry/generated_constructors.rb +62 -0
- data/lib/amount/registry.rb +236 -0
- data/lib/amount/rspec.rb +27 -0
- data/lib/amount/rspec_matchers.rb +105 -0
- data/lib/amount/rspec_support.rb +47 -0
- data/lib/amount/serializer.rb +35 -0
- data/lib/amount/version.rb +5 -0
- data/lib/amount.rb +494 -0
- data/test/dummy/app/models/holding.rb +7 -0
- data/test/dummy/bin/rails +8 -0
- data/test/dummy/config/application.rb +15 -0
- data/test/dummy/config/database.yml +11 -0
- data/test/dummy/config/environment.rb +6 -0
- data/test/dummy/db/schema.rb +9 -0
- data/test/dummy/log/development.log +0 -0
- data/test/dummy/log/test.log +0 -0
- data/test/postgresql_integration_test.rb +71 -0
- data/test/support/amount_test_support.rb +38 -0
- data/test/test_active_record.rb +312 -0
- data/test/test_amount.rb +472 -0
- data/test/test_helper.rb +4 -0
- metadata +105 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
require "active_record"
|
|
5
|
+
require "sqlite3"
|
|
6
|
+
require_relative "../lib/amount/active_record"
|
|
7
|
+
|
|
8
|
+
AmountTestSupport.register_active_record_types!
|
|
9
|
+
|
|
10
|
+
::ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
|
|
11
|
+
|
|
12
|
+
::ActiveRecord::Schema.define do
|
|
13
|
+
create_table :holdings, force: true do |t|
|
|
14
|
+
t.amount :amount, null: true
|
|
15
|
+
t.amount :fee, symbol: :SOL, null: true
|
|
16
|
+
t.amount :reserve, precision: 40, default: "USDC|1.25", null: false
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class Holding < ::ActiveRecord::Base
|
|
21
|
+
has_amount :amount
|
|
22
|
+
has_amount :fee, symbol: :SOL
|
|
23
|
+
has_amount :reserve
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class ValidatedHolding < ::ActiveRecord::Base
|
|
27
|
+
self.table_name = "holdings"
|
|
28
|
+
|
|
29
|
+
has_amount :amount
|
|
30
|
+
has_amount :fee, symbol: :SOL
|
|
31
|
+
has_amount :reserve
|
|
32
|
+
|
|
33
|
+
validates :amount, amount: {
|
|
34
|
+
symbol: :USDC,
|
|
35
|
+
greater_than: "USDC|0",
|
|
36
|
+
less_than_or_equal_to: "USDC|1000"
|
|
37
|
+
}
|
|
38
|
+
validates :fee, amount: {
|
|
39
|
+
symbol: :SOL,
|
|
40
|
+
greater_than: 0,
|
|
41
|
+
less_than: 10,
|
|
42
|
+
allow_nil: true
|
|
43
|
+
}
|
|
44
|
+
validates :reserve, amount: {
|
|
45
|
+
greater_than_or_equal_to: "USDC|1.25"
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class AmountActiveRecordTest < Minitest::Test
|
|
50
|
+
def setup
|
|
51
|
+
Holding.delete_all
|
|
52
|
+
ValidatedHolding.delete_all
|
|
53
|
+
|
|
54
|
+
AmountTestSupport.register_active_record_types!
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def teardown
|
|
58
|
+
Amount.registry.clear!
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def test_migration_dsl_creates_expected_columns
|
|
62
|
+
columns = Holding.columns_hash
|
|
63
|
+
schema_sql = ::ActiveRecord::Base.connection.select_value(<<~SQL)
|
|
64
|
+
SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'holdings'
|
|
65
|
+
SQL
|
|
66
|
+
|
|
67
|
+
assert columns.key?("amount_atomic")
|
|
68
|
+
assert columns.key?("amount_symbol")
|
|
69
|
+
assert columns.key?("fee_atomic")
|
|
70
|
+
refute columns.key?("fee_symbol")
|
|
71
|
+
assert_equal 78, columns.fetch("amount_atomic").precision
|
|
72
|
+
assert_equal 10, columns.fetch("amount_symbol").limit
|
|
73
|
+
assert_equal 40, columns.fetch("reserve_atomic").precision
|
|
74
|
+
assert_equal "USDC", columns.fetch("reserve_symbol").default
|
|
75
|
+
assert_includes schema_sql, "\"amount_atomic\" decimal(78,0)"
|
|
76
|
+
assert_includes schema_sql, "\"reserve_atomic\" decimal(40,0)"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def test_reader_returns_amount
|
|
80
|
+
holding = Holding.create!(amount_atomic: 1_500_000, amount_symbol: "USDC")
|
|
81
|
+
|
|
82
|
+
assert_equal Amount.new("1.5", :USDC), holding.amount
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def test_reader_returns_nil_when_atomic_is_nil
|
|
86
|
+
holding = Holding.create!
|
|
87
|
+
|
|
88
|
+
assert_nil holding.amount
|
|
89
|
+
assert_nil holding.fee
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def test_multi_symbol_writer_accepts_amount_string_and_hash
|
|
93
|
+
holding = Holding.new
|
|
94
|
+
holding.amount = Amount.new("1.5", :USDC)
|
|
95
|
+
assert_equal 1_500_000, holding.amount_atomic
|
|
96
|
+
assert_equal "USDC", holding.amount_symbol
|
|
97
|
+
|
|
98
|
+
holding.amount = "USD|2.50"
|
|
99
|
+
assert_equal 250, holding.amount_atomic
|
|
100
|
+
assert_equal "USD", holding.amount_symbol
|
|
101
|
+
|
|
102
|
+
holding.amount = { "atomic" => 42, "symbol" => "USDC" }
|
|
103
|
+
assert_equal Amount.new(42, :USDC, from: :atomic), holding.amount
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def test_fixed_symbol_writer_accepts_amount_and_raw_numeric
|
|
107
|
+
holding = Holding.new
|
|
108
|
+
holding.fee = 1.25
|
|
109
|
+
|
|
110
|
+
assert_equal 1_250_000_000, holding.fee_atomic
|
|
111
|
+
assert_equal Amount.new("1.25", :SOL), holding.fee
|
|
112
|
+
|
|
113
|
+
holding.fee = Amount.new("2.5", :SOL)
|
|
114
|
+
assert_equal 2_500_000_000, holding.fee_atomic
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def test_fixed_symbol_writer_rejects_wrong_symbol_cleanly
|
|
118
|
+
holding = Holding.new
|
|
119
|
+
holding.fee = Amount.new("1", :USDC)
|
|
120
|
+
|
|
121
|
+
refute holding.valid?
|
|
122
|
+
assert_includes holding.errors[:fee], "expected SOL, got USDC"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def test_multi_symbol_writer_rejects_invalid_input_cleanly
|
|
126
|
+
holding = Holding.new
|
|
127
|
+
holding.amount = 5
|
|
128
|
+
|
|
129
|
+
refute holding.valid?
|
|
130
|
+
assert_includes holding.errors[:amount], "raw numeric assignment requires a fixed symbol"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def test_scope_matches_atomic_and_symbol
|
|
134
|
+
usdc = Holding.create!(amount: Amount.new("1.5", :USDC))
|
|
135
|
+
Holding.create!(amount: Amount.new("1.5", :USD))
|
|
136
|
+
|
|
137
|
+
assert_equal [usdc.id], Holding.where_amount("USDC|1.5").pluck(:id)
|
|
138
|
+
assert_equal [usdc.id], Holding.amount_in(:USDC).pluck(:id)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def test_comparison_scopes_filter_multi_symbol_amounts_by_symbol_and_atomic_value
|
|
142
|
+
low = Holding.create!(amount: "USDC|1.00")
|
|
143
|
+
mid = Holding.create!(amount: "USDC|2.00")
|
|
144
|
+
high = Holding.create!(amount: "USDC|3.00")
|
|
145
|
+
Holding.create!(amount: "USD|2.50")
|
|
146
|
+
|
|
147
|
+
assert_equal [mid.id, high.id], Holding.where_amount_gt("USDC|1.50").pluck(:id)
|
|
148
|
+
assert_equal [mid.id, high.id], Holding.where_amount_gte("USDC|2.00").pluck(:id)
|
|
149
|
+
assert_equal [low.id, mid.id], Holding.where_amount_lt("USDC|2.50").pluck(:id)
|
|
150
|
+
assert_equal [low.id, mid.id], Holding.where_amount_lte("USDC|2.00").pluck(:id)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def test_between_scope_filters_multi_symbol_amounts_with_inclusive_bounds
|
|
154
|
+
low = Holding.create!(amount: "USDC|1.00")
|
|
155
|
+
mid = Holding.create!(amount: "USDC|2.00")
|
|
156
|
+
high = Holding.create!(amount: "USDC|3.00")
|
|
157
|
+
Holding.create!(amount: "USD|2.00")
|
|
158
|
+
|
|
159
|
+
assert_equal [low.id, mid.id, high.id], Holding.where_amount_between("USDC|1.00", "USDC|3.00").pluck(:id)
|
|
160
|
+
assert_equal [mid.id], Holding.where_amount_between("USDC|1.50", "USDC|2.50").pluck(:id)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def test_between_scope_requires_matching_symbols_for_multi_symbol_amounts
|
|
164
|
+
assert_raises(Amount::TypeMismatch) do
|
|
165
|
+
Holding.where_amount_between("USDC|1.00", "USD|2.00").load
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def test_comparison_scopes_support_fixed_symbol_numeric_values
|
|
170
|
+
low = Holding.create!(fee: 0.25)
|
|
171
|
+
mid = Holding.create!(fee: 0.50)
|
|
172
|
+
high = Holding.create!(fee: 0.75)
|
|
173
|
+
|
|
174
|
+
assert_equal [mid.id, high.id], Holding.where_fee_gt(0.25).pluck(:id)
|
|
175
|
+
assert_equal [mid.id, high.id], Holding.where_fee_gte(0.50).pluck(:id)
|
|
176
|
+
assert_equal [low.id, mid.id], Holding.where_fee_lt(0.75).pluck(:id)
|
|
177
|
+
assert_equal [low.id, mid.id], Holding.where_fee_lte(0.50).pluck(:id)
|
|
178
|
+
assert_equal [mid.id], Holding.where_fee_between(0.30, 0.60).pluck(:id)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def test_dirty_tracking_uses_virtual_amount_attribute
|
|
182
|
+
holding = Holding.create!(amount: Amount.new("1.0", :USDC))
|
|
183
|
+
holding.amount = "USDC|2.5"
|
|
184
|
+
|
|
185
|
+
assert holding.amount_changed?
|
|
186
|
+
assert_equal [Amount.new("1.0", :USDC), Amount.new("2.5", :USDC)], holding.amount_change
|
|
187
|
+
|
|
188
|
+
holding.save!
|
|
189
|
+
|
|
190
|
+
assert holding.saved_change_to_amount?
|
|
191
|
+
assert_equal Amount.new("1.0", :USDC), holding.amount_before_last_save
|
|
192
|
+
assert_equal [Amount.new("1.0", :USDC), Amount.new("2.5", :USDC)], holding.saved_change_to_amount
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def test_validation_requires_both_amount_columns_or_neither
|
|
196
|
+
holding = Holding.new(amount_atomic: 100)
|
|
197
|
+
|
|
198
|
+
refute holding.valid?
|
|
199
|
+
assert_includes holding.errors[:amount], "must set both atomic and symbol or neither"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def test_nil_assignment_clears_both_columns
|
|
203
|
+
holding = Holding.create!(amount: Amount.new("1", :USDC))
|
|
204
|
+
holding.amount = nil
|
|
205
|
+
|
|
206
|
+
assert_nil holding.amount_atomic
|
|
207
|
+
assert_nil holding.amount_symbol
|
|
208
|
+
assert_nil holding.amount
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def test_group_sum_works_on_atomic_and_symbol_columns
|
|
212
|
+
Holding.create!(amount: Amount.new("1.5", :USDC))
|
|
213
|
+
Holding.create!(amount: Amount.new("2.0", :USDC))
|
|
214
|
+
Holding.create!(amount: Amount.new("3.0", :USD))
|
|
215
|
+
|
|
216
|
+
sums = Holding.group(:amount_symbol).sum(:amount_atomic)
|
|
217
|
+
|
|
218
|
+
assert_equal 3_500_000, sums["USDC"]
|
|
219
|
+
assert_equal 300, sums["USD"]
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def test_large_atomic_value_round_trips_through_sqlite
|
|
223
|
+
skip "SQLite stores DECIMAL(78,0) values above 64-bit range imprecisely under ActiveRecord"
|
|
224
|
+
|
|
225
|
+
huge_atomic = 10**30
|
|
226
|
+
holding = Holding.create!(amount: Amount.new(huge_atomic, :SOL, from: :atomic))
|
|
227
|
+
|
|
228
|
+
assert_equal huge_atomic, holding.reload.amount.atomic
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def test_amount_validator_accepts_valid_multi_symbol_amount
|
|
232
|
+
holding = ValidatedHolding.new(amount: "USDC|100.00", reserve: "USDC|1.25")
|
|
233
|
+
|
|
234
|
+
assert holding.valid?
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def test_amount_validator_rejects_wrong_symbol
|
|
238
|
+
holding = ValidatedHolding.new(amount: "USD|100.00", reserve: "USDC|1.25")
|
|
239
|
+
|
|
240
|
+
refute holding.valid?
|
|
241
|
+
assert_includes holding.errors[:amount], "must have symbol USDC"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def test_amount_validator_rejects_amount_below_lower_bound
|
|
245
|
+
holding = ValidatedHolding.new(amount: "USDC|0.00", reserve: "USDC|1.25")
|
|
246
|
+
|
|
247
|
+
refute holding.valid?
|
|
248
|
+
assert_includes holding.errors[:amount], "must be greater than USDC|0.0"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def test_amount_validator_rejects_amount_above_upper_bound
|
|
252
|
+
holding = ValidatedHolding.new(amount: "USDC|1000.01", reserve: "USDC|1.25")
|
|
253
|
+
|
|
254
|
+
refute holding.valid?
|
|
255
|
+
assert_includes holding.errors[:amount], "must be less than or equal to USDC|1000.0"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def test_amount_validator_allows_nil_when_allow_nil_is_set
|
|
259
|
+
holding = ValidatedHolding.new(amount: "USDC|100.00", reserve: "USDC|1.25", fee: nil)
|
|
260
|
+
|
|
261
|
+
assert holding.valid?
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def test_amount_validator_supports_fixed_symbol_numeric_thresholds
|
|
265
|
+
holding = ValidatedHolding.new(amount: "USDC|100.00", reserve: "USDC|1.25", fee: 0.5)
|
|
266
|
+
|
|
267
|
+
assert holding.valid?
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def test_amount_validator_rejects_fixed_symbol_amount_below_lower_bound
|
|
271
|
+
holding = ValidatedHolding.new(amount: "USDC|100.00", reserve: "USDC|1.25", fee: 0)
|
|
272
|
+
|
|
273
|
+
refute holding.valid?
|
|
274
|
+
assert_includes holding.errors[:fee], "must be greater than SOL|0.0"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def test_amount_validator_rejects_fixed_symbol_amount_at_exclusive_upper_bound
|
|
278
|
+
holding = ValidatedHolding.new(amount: "USDC|100.00", reserve: "USDC|1.25", fee: 10)
|
|
279
|
+
|
|
280
|
+
refute holding.valid?
|
|
281
|
+
assert_includes holding.errors[:fee], "must be less than SOL|10.0"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def test_amount_validator_rejects_cross_type_threshold_without_rate
|
|
285
|
+
klass = Class.new(::ActiveRecord::Base) do
|
|
286
|
+
self.table_name = "holdings"
|
|
287
|
+
|
|
288
|
+
has_amount :amount
|
|
289
|
+
validates :amount, amount: { greater_than: "USD|1.00" }
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
holding = klass.new(amount: "USDC|2.00")
|
|
293
|
+
|
|
294
|
+
refute holding.valid?
|
|
295
|
+
assert_includes holding.errors[:amount], "cannot compare USDC to USD for greater_than"
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def test_amount_validator_uses_registered_rate_for_cross_type_threshold_when_available
|
|
299
|
+
Amount.register_default_rate :USD, :USDC, "1"
|
|
300
|
+
|
|
301
|
+
klass = Class.new(::ActiveRecord::Base) do
|
|
302
|
+
self.table_name = "holdings"
|
|
303
|
+
|
|
304
|
+
has_amount :amount
|
|
305
|
+
validates :amount, amount: { greater_than: "USD|1.00" }
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
holding = klass.new(amount: "USDC|2.00")
|
|
309
|
+
|
|
310
|
+
assert holding.valid?
|
|
311
|
+
end
|
|
312
|
+
end
|