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.
@@ -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