mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 03:54:08 +00:00
Add Binance support, heavily inspired by the Coinbase one (#1317)
* feat: add Binance support (Items, Accounts, Importers, Processor, and Sync) * refactor: deduplicate 'stablecoins' constant and push stale_rate filter to SQL --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
184
test/controllers/binance_items_controller_test.rb
Normal file
184
test/controllers/binance_items_controller_test.rb
Normal file
@@ -0,0 +1,184 @@
|
||||
require "test_helper"
|
||||
|
||||
class BinanceItemsControllerTest < ActionDispatch::IntegrationTest
|
||||
include ActiveJob::TestHelper
|
||||
|
||||
setup do
|
||||
sign_in users(:family_admin)
|
||||
@family = families(:dylan_family)
|
||||
@binance_item = BinanceItem.create!(
|
||||
family: @family,
|
||||
name: "Test Binance",
|
||||
api_key: "test_key",
|
||||
api_secret: "test_secret"
|
||||
)
|
||||
end
|
||||
|
||||
test "should destroy binance item" do
|
||||
assert_difference("BinanceItem.count", 0) do # doesn't delete immediately
|
||||
delete binance_item_url(@binance_item)
|
||||
end
|
||||
|
||||
assert_redirected_to settings_providers_path
|
||||
@binance_item.reload
|
||||
assert @binance_item.scheduled_for_deletion?
|
||||
end
|
||||
|
||||
test "should sync binance item" do
|
||||
post sync_binance_item_url(@binance_item)
|
||||
assert_response :redirect
|
||||
end
|
||||
|
||||
test "should show setup_accounts page" do
|
||||
get setup_accounts_binance_item_url(@binance_item)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "complete_account_setup creates accounts for selected binance_accounts" do
|
||||
binance_account = @binance_item.binance_accounts.create!(
|
||||
name: "Spot Portfolio",
|
||||
account_type: "spot",
|
||||
currency: "USD",
|
||||
current_balance: 1000.0
|
||||
)
|
||||
|
||||
assert_difference "Account.count", 1 do
|
||||
post complete_account_setup_binance_item_url(@binance_item), params: {
|
||||
selected_accounts: [ binance_account.id ]
|
||||
}
|
||||
end
|
||||
|
||||
assert_response :redirect
|
||||
|
||||
binance_account.reload
|
||||
assert_not_nil binance_account.current_account
|
||||
assert_equal "Crypto", binance_account.current_account.accountable_type
|
||||
end
|
||||
|
||||
test "complete_account_setup with no selection shows message" do
|
||||
@binance_item.binance_accounts.create!(
|
||||
name: "Spot Portfolio",
|
||||
account_type: "spot",
|
||||
currency: "USD",
|
||||
current_balance: 1000.0
|
||||
)
|
||||
|
||||
assert_no_difference "Account.count" do
|
||||
post complete_account_setup_binance_item_url(@binance_item), params: {
|
||||
selected_accounts: []
|
||||
}
|
||||
end
|
||||
|
||||
assert_response :redirect
|
||||
end
|
||||
|
||||
test "complete_account_setup skips already linked accounts" do
|
||||
binance_account = @binance_item.binance_accounts.create!(
|
||||
name: "Spot Portfolio",
|
||||
account_type: "spot",
|
||||
currency: "USD",
|
||||
current_balance: 1000.0
|
||||
)
|
||||
|
||||
# Pre-link the account
|
||||
account = Account.create!(
|
||||
family: @family,
|
||||
name: "Existing Binance",
|
||||
balance: 1000,
|
||||
currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
AccountProvider.create!(account: account, provider: binance_account)
|
||||
|
||||
assert_no_difference "Account.count" do
|
||||
post complete_account_setup_binance_item_url(@binance_item), params: {
|
||||
selected_accounts: [ binance_account.id ]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
test "cannot access other family's binance_item" do
|
||||
other_family = families(:empty)
|
||||
other_item = BinanceItem.create!(
|
||||
family: other_family,
|
||||
name: "Other Binance",
|
||||
api_key: "other_test_key",
|
||||
api_secret: "other_test_secret"
|
||||
)
|
||||
|
||||
get setup_accounts_binance_item_url(other_item)
|
||||
assert_response :not_found
|
||||
end
|
||||
|
||||
test "link_existing_account links manual account to binance_account" do
|
||||
manual_account = Account.create!(
|
||||
family: @family,
|
||||
name: "Manual Crypto",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
|
||||
binance_account = @binance_item.binance_accounts.create!(
|
||||
name: "Spot Portfolio",
|
||||
account_type: "spot",
|
||||
currency: "USD",
|
||||
current_balance: 1000.0
|
||||
)
|
||||
|
||||
assert_difference "AccountProvider.count", 1 do
|
||||
post link_existing_account_binance_items_url, params: {
|
||||
account_id: manual_account.id,
|
||||
binance_account_id: binance_account.id
|
||||
}
|
||||
end
|
||||
|
||||
binance_account.reload
|
||||
assert_equal manual_account, binance_account.current_account
|
||||
end
|
||||
|
||||
test "link_existing_account rejects account with existing provider" do
|
||||
linked_account = Account.create!(
|
||||
family: @family,
|
||||
name: "Already Linked",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
|
||||
other_binance_account = @binance_item.binance_accounts.create!(
|
||||
name: "Other Account",
|
||||
account_type: "margin",
|
||||
currency: "USD",
|
||||
current_balance: 500.0
|
||||
)
|
||||
AccountProvider.create!(account: linked_account, provider: other_binance_account)
|
||||
|
||||
binance_account = @binance_item.binance_accounts.create!(
|
||||
name: "Spot Portfolio",
|
||||
account_type: "spot",
|
||||
currency: "USD",
|
||||
current_balance: 1000.0
|
||||
)
|
||||
|
||||
assert_no_difference "AccountProvider.count" do
|
||||
post link_existing_account_binance_items_url, params: {
|
||||
account_id: linked_account.id,
|
||||
binance_account_id: binance_account.id
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
test "select_existing_account renders without layout" do
|
||||
account = Account.create!(
|
||||
family: @family,
|
||||
name: "Manual Account",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
|
||||
get select_existing_account_binance_items_url, params: { account_id: account.id }
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
6
test/fixtures/binance_accounts.yml
vendored
Normal file
6
test/fixtures/binance_accounts.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
one:
|
||||
binance_item: one
|
||||
name: Binance
|
||||
account_type: combined
|
||||
currency: USD
|
||||
current_balance: 15000.00
|
||||
18
test/fixtures/binance_items.yml
vendored
Normal file
18
test/fixtures/binance_items.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
one:
|
||||
family: dylan_family
|
||||
name: My Binance
|
||||
api_key: test_api_key_123
|
||||
api_secret: test_api_secret_456
|
||||
status: good
|
||||
institution_name: Binance
|
||||
institution_domain: binance.com
|
||||
institution_url: https://www.binance.com
|
||||
institution_color: "#F0B90B"
|
||||
|
||||
requires_update:
|
||||
family: dylan_family
|
||||
name: Stale Binance
|
||||
api_key: old_key
|
||||
api_secret: old_secret
|
||||
status: requires_update
|
||||
institution_name: Binance
|
||||
72
test/models/binance_account/holdings_processor_test.rb
Normal file
72
test/models/binance_account/holdings_processor_test.rb
Normal file
@@ -0,0 +1,72 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class BinanceAccount::HoldingsProcessorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@family.update!(currency: "EUR")
|
||||
|
||||
@item = BinanceItem.create!(
|
||||
family: @family, name: "Binance", api_key: "k", api_secret: "s"
|
||||
)
|
||||
@ba = @item.binance_accounts.create!(
|
||||
name: "Binance",
|
||||
account_type: "combined",
|
||||
currency: "USD",
|
||||
current_balance: 1000,
|
||||
raw_payload: {
|
||||
"assets" => [ { "symbol" => "BTC", "total" => "0.5", "source" => "spot" } ]
|
||||
}
|
||||
)
|
||||
@account = Account.create!(
|
||||
family: @family,
|
||||
name: "Binance",
|
||||
balance: 0,
|
||||
currency: "EUR",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
AccountProvider.create!(account: @account, provider: @ba)
|
||||
end
|
||||
|
||||
test "converts holding amount to family currency when exact rate exists" do
|
||||
ExchangeRate.create!(from_currency: "USD", to_currency: "EUR",
|
||||
date: Date.current, rate: 0.92)
|
||||
|
||||
Security.find_or_create_by!(ticker: "CRYPTO:BTC") do |s|
|
||||
s.name = "BTC"
|
||||
s.exchange_operating_mic = "XBNC"
|
||||
end
|
||||
|
||||
BinanceAccount::HoldingsProcessor.any_instance
|
||||
.stubs(:fetch_price).with("BTC").returns(60_000.0)
|
||||
|
||||
import_adapter = mock
|
||||
import_adapter.expects(:import_holding).with(
|
||||
has_entries(currency: "EUR", amount: 27_600.0)
|
||||
)
|
||||
Account::ProviderImportAdapter.stubs(:new).returns(import_adapter)
|
||||
|
||||
BinanceAccount::HoldingsProcessor.new(@ba).process
|
||||
end
|
||||
|
||||
test "uses raw USD amount when no rate is available" do
|
||||
ExchangeRate.stubs(:find_or_fetch_rate).returns(nil)
|
||||
|
||||
Security.find_or_create_by!(ticker: "CRYPTO:BTC") do |s|
|
||||
s.name = "BTC"
|
||||
s.exchange_operating_mic = "XBNC"
|
||||
end
|
||||
|
||||
BinanceAccount::HoldingsProcessor.any_instance
|
||||
.stubs(:fetch_price).with("BTC").returns(60_000.0)
|
||||
|
||||
import_adapter = mock
|
||||
import_adapter.expects(:import_holding).with(
|
||||
has_entries(currency: "EUR", amount: 30_000.0)
|
||||
)
|
||||
Account::ProviderImportAdapter.stubs(:new).returns(import_adapter)
|
||||
|
||||
BinanceAccount::HoldingsProcessor.new(@ba).process
|
||||
end
|
||||
end
|
||||
89
test/models/binance_account/processor_test.rb
Normal file
89
test/models/binance_account/processor_test.rb
Normal file
@@ -0,0 +1,89 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class BinanceAccount::ProcessorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@family.update!(currency: "EUR")
|
||||
|
||||
@item = BinanceItem.create!(
|
||||
family: @family, name: "Binance", api_key: "k", api_secret: "s"
|
||||
)
|
||||
@ba = @item.binance_accounts.create!(
|
||||
name: "Binance", account_type: "combined", currency: "USD", current_balance: 1000
|
||||
)
|
||||
@account = Account.create!(
|
||||
family: @family,
|
||||
name: "Binance",
|
||||
balance: 0,
|
||||
currency: "EUR",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
AccountProvider.create!(account: @account, provider: @ba)
|
||||
|
||||
BinanceAccount::HoldingsProcessor.any_instance.stubs(:process).returns(nil)
|
||||
@ba.stubs(:binance_item).returns(
|
||||
stub(binance_provider: nil, family: @family)
|
||||
)
|
||||
end
|
||||
|
||||
test "converts USD balance to family currency when exact rate exists" do
|
||||
ExchangeRate.create!(from_currency: "USD", to_currency: "EUR",
|
||||
date: Date.current, rate: 0.92)
|
||||
|
||||
BinanceAccount::Processor.new(@ba).process
|
||||
|
||||
@account.reload
|
||||
@ba.reload
|
||||
assert_equal "EUR", @account.currency
|
||||
assert_in_delta 920.0, @account.balance, 0.01
|
||||
assert_equal false, @ba.extra.dig("binance", "stale_rate")
|
||||
end
|
||||
|
||||
test "uses nearest rate and sets stale flag when exact rate missing" do
|
||||
ExchangeRate.create!(from_currency: "USD", to_currency: "EUR",
|
||||
date: Date.current - 3, rate: 0.90)
|
||||
|
||||
BinanceAccount::Processor.new(@ba).process
|
||||
|
||||
@account.reload
|
||||
@ba.reload
|
||||
assert_equal "EUR", @account.currency
|
||||
assert_in_delta 900.0, @account.balance, 0.01
|
||||
assert_equal true, @ba.extra.dig("binance", "stale_rate")
|
||||
end
|
||||
|
||||
test "falls back to USD amount and sets stale flag when no rate available" do
|
||||
ExchangeRate.expects(:find_or_fetch_rate).returns(nil)
|
||||
|
||||
BinanceAccount::Processor.new(@ba).process
|
||||
|
||||
@account.reload
|
||||
@ba.reload
|
||||
assert_in_delta 1000.0, @account.balance, 0.01
|
||||
assert_equal true, @ba.extra.dig("binance", "stale_rate")
|
||||
end
|
||||
|
||||
test "clears stale flag on subsequent sync when exact rate found" do
|
||||
@ba.update!(extra: { "binance" => { "stale_rate" => true } })
|
||||
ExchangeRate.create!(from_currency: "USD", to_currency: "EUR",
|
||||
date: Date.current, rate: 0.92)
|
||||
|
||||
BinanceAccount::Processor.new(@ba).process
|
||||
|
||||
@account.reload
|
||||
@ba.reload
|
||||
assert_equal false, @ba.extra.dig("binance", "stale_rate")
|
||||
end
|
||||
|
||||
test "does not convert when family uses USD" do
|
||||
@family.update!(currency: "USD")
|
||||
|
||||
BinanceAccount::Processor.new(@ba).process
|
||||
|
||||
@account.reload
|
||||
assert_equal "USD", @account.currency
|
||||
assert_in_delta 1000.0, @account.balance, 0.01
|
||||
end
|
||||
end
|
||||
75
test/models/binance_account/usd_converter_test.rb
Normal file
75
test/models/binance_account/usd_converter_test.rb
Normal file
@@ -0,0 +1,75 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class BinanceAccount::UsdConverterTest < ActiveSupport::TestCase
|
||||
# A minimal host class that includes the concern so we can test it in isolation
|
||||
class Host
|
||||
include BinanceAccount::UsdConverter
|
||||
|
||||
def initialize(family_currency)
|
||||
@family_currency = family_currency
|
||||
end
|
||||
|
||||
def target_currency
|
||||
@family_currency
|
||||
end
|
||||
end
|
||||
|
||||
test "returns original amount unchanged when target is USD" do
|
||||
host = Host.new("USD")
|
||||
amount, stale, rate_date = host.send(:convert_from_usd, 1000.0, date: Date.current)
|
||||
assert_equal 1000.0, amount
|
||||
assert_equal false, stale
|
||||
assert_nil rate_date
|
||||
end
|
||||
|
||||
test "returns converted amount when exact rate exists" do
|
||||
date = Date.new(2026, 3, 28)
|
||||
ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date: date, rate: 0.92)
|
||||
|
||||
host = Host.new("EUR")
|
||||
amount, stale, rate_date = host.send(:convert_from_usd, 1000.0, date: date)
|
||||
|
||||
assert_in_delta 920.0, amount, 0.01
|
||||
assert_equal false, stale
|
||||
assert_nil rate_date
|
||||
end
|
||||
|
||||
test "marks stale and returns converted amount when nearest rate used" do
|
||||
old_date = Date.new(2026, 3, 25)
|
||||
ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date: old_date, rate: 0.91)
|
||||
|
||||
host = Host.new("EUR")
|
||||
amount, stale, rate_date = host.send(:convert_from_usd, 1000.0, date: Date.new(2026, 3, 28))
|
||||
|
||||
assert_in_delta 910.0, amount, 0.01
|
||||
assert_equal true, stale
|
||||
assert_equal old_date, rate_date
|
||||
end
|
||||
|
||||
test "returns raw USD amount with stale flag when no rate available" do
|
||||
host = Host.new("EUR")
|
||||
ExchangeRate.expects(:find_or_fetch_rate).returns(nil)
|
||||
|
||||
amount, stale, rate_date = host.send(:convert_from_usd, 1000.0, date: Date.new(2026, 3, 28))
|
||||
|
||||
assert_equal 1000.0, amount
|
||||
assert_equal true, stale
|
||||
assert_nil rate_date
|
||||
end
|
||||
|
||||
test "build_stale_extra returns correct hash when stale" do
|
||||
host = Host.new("EUR")
|
||||
result = host.send(:build_stale_extra, true, Date.new(2026, 3, 25), Date.new(2026, 3, 28))
|
||||
|
||||
assert_equal({ "binance" => { "stale_rate" => true, "rate_date_used" => "2026-03-25", "rate_target_date" => "2026-03-28" } }, result)
|
||||
end
|
||||
|
||||
test "build_stale_extra returns cleared hash when not stale" do
|
||||
host = Host.new("EUR")
|
||||
result = host.send(:build_stale_extra, false, nil, Date.new(2026, 3, 28))
|
||||
|
||||
assert_equal({ "binance" => { "stale_rate" => false } }, result)
|
||||
end
|
||||
end
|
||||
64
test/models/binance_account_test.rb
Normal file
64
test/models/binance_account_test.rb
Normal file
@@ -0,0 +1,64 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class BinanceAccountTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@item = binance_items(:one)
|
||||
@ba = binance_accounts(:one)
|
||||
end
|
||||
|
||||
test "belongs to binance_item" do
|
||||
assert_equal @item, @ba.binance_item
|
||||
end
|
||||
|
||||
test "validates presence of name" do
|
||||
ba = @item.binance_accounts.build(account_type: "combined", currency: "USD")
|
||||
assert_not ba.valid?
|
||||
assert_includes ba.errors[:name], "can't be blank"
|
||||
end
|
||||
|
||||
test "validates presence of currency" do
|
||||
ba = @item.binance_accounts.build(name: "Binance", account_type: "combined")
|
||||
assert_not ba.valid?
|
||||
assert_includes ba.errors[:currency], "can't be blank"
|
||||
end
|
||||
|
||||
test "ensure_account_provider! creates AccountProvider" do
|
||||
account = Account.create!(
|
||||
family: @family, name: "Binance", balance: 0, currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
|
||||
@ba.ensure_account_provider!(account)
|
||||
|
||||
ap = AccountProvider.find_by(provider: @ba)
|
||||
assert_not_nil ap
|
||||
assert_equal account, ap.account
|
||||
end
|
||||
|
||||
test "ensure_account_provider! is idempotent" do
|
||||
account = Account.create!(
|
||||
family: @family, name: "Binance", balance: 0, currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
|
||||
@ba.ensure_account_provider!(account)
|
||||
@ba.ensure_account_provider!(account)
|
||||
|
||||
assert_equal 1, AccountProvider.where(provider: @ba).count
|
||||
end
|
||||
|
||||
test "current_account returns linked account" do
|
||||
assert_nil @ba.current_account
|
||||
|
||||
account = Account.create!(
|
||||
family: @family, name: "Binance", balance: 0, currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
AccountProvider.create!(account: account, provider: @ba)
|
||||
|
||||
assert_equal account, @ba.reload.current_account
|
||||
end
|
||||
end
|
||||
58
test/models/binance_item/earn_importer_test.rb
Normal file
58
test/models/binance_item/earn_importer_test.rb
Normal file
@@ -0,0 +1,58 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class BinanceItem::EarnImporterTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@provider = mock
|
||||
@family = families(:dylan_family)
|
||||
@item = BinanceItem.create!(family: @family, name: "B", api_key: "k", api_secret: "s")
|
||||
end
|
||||
|
||||
test "merges flexible and locked positions with source=earn" do
|
||||
@provider.stubs(:get_simple_earn_flexible).returns({
|
||||
"rows" => [ { "asset" => "USDT", "totalAmount" => "500.0" } ]
|
||||
})
|
||||
@provider.stubs(:get_simple_earn_locked).returns({
|
||||
"rows" => [ { "asset" => "BNB", "amount" => "10.0" } ]
|
||||
})
|
||||
|
||||
result = BinanceItem::EarnImporter.new(@item, provider: @provider).import
|
||||
|
||||
assert_equal "earn", result[:source]
|
||||
assert_equal 2, result[:assets].size
|
||||
usdt = result[:assets].find { |a| a[:symbol] == "USDT" }
|
||||
assert_equal "500.0", usdt[:total]
|
||||
assert_equal "500.0", usdt[:free]
|
||||
assert_equal "0.0", usdt[:locked]
|
||||
bnb = result[:assets].find { |a| a[:symbol] == "BNB" }
|
||||
assert_equal "10.0", bnb[:total]
|
||||
assert_equal "0.0", bnb[:free]
|
||||
assert_equal "10.0", bnb[:locked]
|
||||
end
|
||||
|
||||
test "deduplicates assets from flexible and locked by summing" do
|
||||
@provider.stubs(:get_simple_earn_flexible).returns({
|
||||
"rows" => [ { "asset" => "BTC", "totalAmount" => "1.0" } ]
|
||||
})
|
||||
@provider.stubs(:get_simple_earn_locked).returns({
|
||||
"rows" => [ { "asset" => "BTC", "amount" => "0.5" } ]
|
||||
})
|
||||
|
||||
result = BinanceItem::EarnImporter.new(@item, provider: @provider).import
|
||||
|
||||
assert_equal 1, result[:assets].size
|
||||
assert_equal "1.5", result[:assets].first[:total]
|
||||
end
|
||||
|
||||
test "returns empty assets when both APIs fail" do
|
||||
@provider.stubs(:get_simple_earn_flexible).raises(Provider::Binance::ApiError, "error")
|
||||
@provider.stubs(:get_simple_earn_locked).raises(Provider::Binance::ApiError, "error")
|
||||
|
||||
result = BinanceItem::EarnImporter.new(@item, provider: @provider).import
|
||||
|
||||
assert_equal "earn", result[:source]
|
||||
assert_equal [], result[:assets]
|
||||
assert_equal({ "flexible" => nil, "locked" => nil }, result[:raw])
|
||||
end
|
||||
end
|
||||
85
test/models/binance_item/importer_test.rb
Normal file
85
test/models/binance_item/importer_test.rb
Normal file
@@ -0,0 +1,85 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class BinanceItem::ImporterTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@item = BinanceItem.create!(family: @family, name: "B", api_key: "k", api_secret: "s")
|
||||
@provider = mock
|
||||
@provider.stubs(:get_spot_price).returns("50000.0")
|
||||
|
||||
stub_spot_result([ { symbol: "BTC", free: "1.0", locked: "0.0", total: "1.0" } ])
|
||||
stub_margin_result([])
|
||||
stub_earn_result([])
|
||||
end
|
||||
|
||||
test "creates a binance_account of type combined" do
|
||||
assert_difference "@item.binance_accounts.count", 1 do
|
||||
BinanceItem::Importer.new(@item, binance_provider: @provider).import
|
||||
end
|
||||
|
||||
ba = @item.binance_accounts.first
|
||||
assert_equal "combined", ba.account_type
|
||||
assert_equal "USD", ba.currency
|
||||
end
|
||||
|
||||
test "calculates combined USD balance" do
|
||||
@provider.stubs(:get_spot_price).with("BTCUSDT").returns("50000.0")
|
||||
|
||||
BinanceItem::Importer.new(@item, binance_provider: @provider).import
|
||||
|
||||
ba = @item.binance_accounts.first
|
||||
assert_in_delta 50000.0, ba.current_balance.to_f, 0.01
|
||||
end
|
||||
|
||||
test "stablecoins counted at 1.0 without API call" do
|
||||
stub_spot_result([ { symbol: "USDT", free: "1000.0", locked: "0.0", total: "1000.0" } ])
|
||||
|
||||
@provider.expects(:get_spot_price).never
|
||||
|
||||
BinanceItem::Importer.new(@item, binance_provider: @provider).import
|
||||
|
||||
ba = @item.binance_accounts.first
|
||||
assert_in_delta 1000.0, ba.current_balance.to_f, 0.01
|
||||
end
|
||||
|
||||
test "skips BinanceAccount creation when all sources empty" do
|
||||
stub_spot_result([])
|
||||
stub_margin_result([])
|
||||
stub_earn_result([])
|
||||
|
||||
assert_no_difference "@item.binance_accounts.count" do
|
||||
BinanceItem::Importer.new(@item, binance_provider: @provider).import
|
||||
end
|
||||
end
|
||||
|
||||
test "stores source breakdown in raw_payload" do
|
||||
BinanceItem::Importer.new(@item, binance_provider: @provider).import
|
||||
|
||||
ba = @item.binance_accounts.first
|
||||
assert ba.raw_payload.key?("spot")
|
||||
assert ba.raw_payload.key?("margin")
|
||||
assert ba.raw_payload.key?("earn")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def stub_spot_result(assets)
|
||||
BinanceItem::SpotImporter.any_instance.stubs(:import).returns(
|
||||
{ assets: assets, raw: {}, source: "spot" }
|
||||
)
|
||||
end
|
||||
|
||||
def stub_margin_result(assets)
|
||||
BinanceItem::MarginImporter.any_instance.stubs(:import).returns(
|
||||
{ assets: assets, raw: {}, source: "margin" }
|
||||
)
|
||||
end
|
||||
|
||||
def stub_earn_result(assets)
|
||||
BinanceItem::EarnImporter.any_instance.stubs(:import).returns(
|
||||
{ assets: assets, raw: {}, source: "earn" }
|
||||
)
|
||||
end
|
||||
end
|
||||
37
test/models/binance_item/margin_importer_test.rb
Normal file
37
test/models/binance_item/margin_importer_test.rb
Normal file
@@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class BinanceItem::MarginImporterTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@provider = mock
|
||||
@family = families(:dylan_family)
|
||||
@item = BinanceItem.create!(family: @family, name: "B", api_key: "k", api_secret: "s")
|
||||
end
|
||||
|
||||
test "returns normalized assets from userAssets with source=margin" do
|
||||
@provider.stubs(:get_margin_account).returns({
|
||||
"userAssets" => [
|
||||
{ "asset" => "BTC", "free" => "0.1", "locked" => "0.0", "netAsset" => "0.1" },
|
||||
{ "asset" => "ETH", "free" => "0.0", "locked" => "0.0", "netAsset" => "0.0" }
|
||||
]
|
||||
})
|
||||
|
||||
result = BinanceItem::MarginImporter.new(@item, provider: @provider).import
|
||||
|
||||
assert_equal "margin", result[:source]
|
||||
assert_equal 1, result[:assets].size
|
||||
btc = result[:assets].first
|
||||
assert_equal "BTC", btc[:symbol]
|
||||
assert_equal "0.1", btc[:total]
|
||||
end
|
||||
|
||||
test "returns empty on API error" do
|
||||
@provider.stubs(:get_margin_account).raises(Provider::Binance::ApiError, "WAF")
|
||||
|
||||
result = BinanceItem::MarginImporter.new(@item, provider: @provider).import
|
||||
|
||||
assert_equal "margin", result[:source]
|
||||
assert_equal [], result[:assets]
|
||||
end
|
||||
end
|
||||
53
test/models/binance_item/spot_importer_test.rb
Normal file
53
test/models/binance_item/spot_importer_test.rb
Normal file
@@ -0,0 +1,53 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class BinanceItem::SpotImporterTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@provider = mock
|
||||
@family = families(:dylan_family)
|
||||
@item = BinanceItem.create!(family: @family, name: "B", api_key: "k", api_secret: "s")
|
||||
end
|
||||
|
||||
test "returns normalized assets with source=spot" do
|
||||
@provider.stubs(:get_spot_account).returns({
|
||||
"balances" => [
|
||||
{ "asset" => "BTC", "free" => "1.5", "locked" => "0.5" },
|
||||
{ "asset" => "ETH", "free" => "10.0", "locked" => "0.0" },
|
||||
{ "asset" => "SHIB", "free" => "0.0", "locked" => "0.0" }
|
||||
]
|
||||
})
|
||||
|
||||
result = BinanceItem::SpotImporter.new(@item, provider: @provider).import
|
||||
|
||||
assert_equal "spot", result[:source]
|
||||
assert_equal 2, result[:assets].size # SHIB filtered out (zero balance)
|
||||
btc = result[:assets].find { |a| a[:symbol] == "BTC" }
|
||||
assert_equal "1.5", btc[:free]
|
||||
assert_equal "0.5", btc[:locked]
|
||||
assert_equal "2.0", btc[:total]
|
||||
end
|
||||
|
||||
test "returns empty assets on API error" do
|
||||
@provider.stubs(:get_spot_account).raises(Provider::Binance::AuthenticationError, "Invalid key")
|
||||
|
||||
result = BinanceItem::SpotImporter.new(@item, provider: @provider).import
|
||||
|
||||
assert_equal "spot", result[:source]
|
||||
assert_equal [], result[:assets]
|
||||
assert_nil result[:raw]
|
||||
end
|
||||
|
||||
test "filters out zero-balance assets" do
|
||||
@provider.stubs(:get_spot_account).returns({
|
||||
"balances" => [
|
||||
{ "asset" => "BTC", "free" => "0.0", "locked" => "0.0" },
|
||||
{ "asset" => "ETH", "free" => "0.0", "locked" => "0.0" }
|
||||
]
|
||||
})
|
||||
|
||||
result = BinanceItem::SpotImporter.new(@item, provider: @provider).import
|
||||
|
||||
assert_equal [], result[:assets]
|
||||
end
|
||||
end
|
||||
111
test/models/binance_item_test.rb
Normal file
111
test/models/binance_item_test.rb
Normal file
@@ -0,0 +1,111 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class BinanceItemTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@item = BinanceItem.create!(
|
||||
family: @family,
|
||||
name: "My Binance",
|
||||
api_key: "test_key",
|
||||
api_secret: "test_secret"
|
||||
)
|
||||
end
|
||||
|
||||
test "belongs to family" do
|
||||
assert_equal @family, @item.family
|
||||
end
|
||||
|
||||
test "has good status by default" do
|
||||
assert_equal "good", @item.status
|
||||
end
|
||||
|
||||
test "validates presence of name" do
|
||||
item = BinanceItem.new(family: @family, api_key: "k", api_secret: "s")
|
||||
assert_not item.valid?
|
||||
assert_includes item.errors[:name], "can't be blank"
|
||||
end
|
||||
|
||||
test "validates presence of api_key" do
|
||||
item = BinanceItem.new(family: @family, name: "B", api_secret: "s")
|
||||
assert_not item.valid?
|
||||
assert_includes item.errors[:api_key], "can't be blank"
|
||||
end
|
||||
|
||||
test "validates presence of api_secret" do
|
||||
item = BinanceItem.new(family: @family, name: "B", api_key: "k")
|
||||
assert_not item.valid?
|
||||
assert_includes item.errors[:api_secret], "can't be blank"
|
||||
end
|
||||
|
||||
test "active scope excludes scheduled for deletion" do
|
||||
@item.update!(scheduled_for_deletion: true)
|
||||
refute_includes BinanceItem.active.to_a, @item
|
||||
end
|
||||
|
||||
test "credentials_configured? returns true when both keys present" do
|
||||
assert @item.credentials_configured?
|
||||
end
|
||||
|
||||
test "credentials_configured? returns false when api_key nil" do
|
||||
@item.api_key = nil
|
||||
refute @item.credentials_configured?
|
||||
end
|
||||
|
||||
test "destroy_later marks for deletion" do
|
||||
@item.destroy_later
|
||||
assert @item.scheduled_for_deletion?
|
||||
end
|
||||
|
||||
test "set_binance_institution_defaults! sets metadata" do
|
||||
@item.set_binance_institution_defaults!
|
||||
assert_equal "Binance", @item.institution_name
|
||||
assert_equal "binance.com", @item.institution_domain
|
||||
assert_equal "https://www.binance.com", @item.institution_url
|
||||
assert_equal "#F0B90B", @item.institution_color
|
||||
end
|
||||
|
||||
test "sync_status_summary with no accounts" do
|
||||
assert_equal I18n.t("binance_items.binance_item.sync_status.no_accounts"), @item.sync_status_summary
|
||||
end
|
||||
|
||||
test "sync_status_summary with all accounts linked" do
|
||||
ba = @item.binance_accounts.create!(name: "Binance Combined", account_type: "combined", currency: "USD")
|
||||
account = Account.create!(
|
||||
family: @family, name: "Binance", balance: 0, currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
AccountProvider.create!(account: account, provider: ba)
|
||||
|
||||
assert_equal I18n.t("binance_items.binance_item.sync_status.all_synced", count: 1), @item.sync_status_summary
|
||||
end
|
||||
|
||||
test "sync_status_summary with partial sync" do
|
||||
# Linked account
|
||||
ba1 = @item.binance_accounts.create!(name: "Binance Spot", account_type: "spot", currency: "USD")
|
||||
account = Account.create!(
|
||||
family: @family, name: "Binance Spot", balance: 0, currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
AccountProvider.create!(account: account, provider: ba1)
|
||||
|
||||
# Unlinked account
|
||||
@item.binance_accounts.create!(name: "Binance Earn", account_type: "earn", currency: "USD")
|
||||
|
||||
assert_equal I18n.t("binance_items.binance_item.sync_status.partial_sync", linked_count: 1, unlinked_count: 1), @item.sync_status_summary
|
||||
end
|
||||
|
||||
test "linked_accounts_count returns correct count" do
|
||||
ba = @item.binance_accounts.create!(name: "Binance", account_type: "combined", currency: "USD")
|
||||
assert_equal 0, @item.linked_accounts_count
|
||||
|
||||
account = Account.create!(
|
||||
family: @family, name: "Binance", balance: 0, currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
AccountProvider.create!(account: account, provider: ba)
|
||||
|
||||
assert_equal 1, @item.linked_accounts_count
|
||||
end
|
||||
end
|
||||
62
test/models/provider/binance_test.rb
Normal file
62
test/models/provider/binance_test.rb
Normal file
@@ -0,0 +1,62 @@
|
||||
require "test_helper"
|
||||
|
||||
class Provider::BinanceTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@provider = Provider::Binance.new(api_key: "test_key", api_secret: "test_secret")
|
||||
end
|
||||
|
||||
test "sign produces HMAC-SHA256 hex digest" do
|
||||
params = { "timestamp" => "1000", "recvWindow" => "5000" }
|
||||
sig = @provider.send(:sign, params)
|
||||
expected = OpenSSL::HMAC.hexdigest("sha256", "test_secret", "recvWindow=5000×tamp=1000")
|
||||
assert_equal expected, sig
|
||||
end
|
||||
|
||||
test "auth_headers include X-MBX-APIKEY" do
|
||||
headers = @provider.send(:auth_headers)
|
||||
assert_equal "test_key", headers["X-MBX-APIKEY"]
|
||||
end
|
||||
|
||||
test "timestamp_params returns hash with timestamp and recvWindow" do
|
||||
params = @provider.send(:timestamp_params)
|
||||
assert params["timestamp"].present?
|
||||
assert_in_delta Time.current.to_i * 1000, params["timestamp"].to_i, 5000
|
||||
assert_equal "5000", params["recvWindow"]
|
||||
end
|
||||
|
||||
test "handle_response raises AuthenticationError on 401" do
|
||||
response = mock_httparty_response(401, { "msg" => "Invalid API-key" })
|
||||
assert_raises(Provider::Binance::AuthenticationError) do
|
||||
@provider.send(:handle_response, response)
|
||||
end
|
||||
end
|
||||
|
||||
test "handle_response raises RateLimitError on 429" do
|
||||
response = mock_httparty_response(429, {})
|
||||
assert_raises(Provider::Binance::RateLimitError) do
|
||||
@provider.send(:handle_response, response)
|
||||
end
|
||||
end
|
||||
|
||||
test "handle_response raises ApiError on other non-2xx" do
|
||||
response = mock_httparty_response(403, { "msg" => "WAF Limit" })
|
||||
assert_raises(Provider::Binance::ApiError) do
|
||||
@provider.send(:handle_response, response)
|
||||
end
|
||||
end
|
||||
|
||||
test "handle_response returns parsed body on 200" do
|
||||
response = mock_httparty_response(200, { "balances" => [] })
|
||||
result = @provider.send(:handle_response, response)
|
||||
assert_equal({ "balances" => [] }, result)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mock_httparty_response(code, body)
|
||||
response = mock
|
||||
response.stubs(:code).returns(code)
|
||||
response.stubs(:parsed_response).returns(body)
|
||||
response
|
||||
end
|
||||
end
|
||||
@@ -137,7 +137,7 @@ class SimplefinEntry::ProcessorTest < ActiveSupport::TestCase
|
||||
|
||||
entry = @account.entries.find_by!(external_id: "simplefin_tx_pending_zero_posted_1", source: "simplefin")
|
||||
# For depository accounts, processor prefers posted, then transacted; posted==0 should be treated as missing
|
||||
assert_equal Time.at(t_epoch).to_date, entry.date, "expected entry.date to use transacted_at when posted==0"
|
||||
assert_equal Time.at(t_epoch).utc.to_date, entry.date, "expected entry.date to use transacted_at when posted==0"
|
||||
sf = entry.transaction.extra.fetch("simplefin")
|
||||
assert_equal true, sf["pending"], "expected pending flag to be true when posted==0 and/or pending=true"
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user