mirror of
https://github.com/we-promise/sure.git
synced 2026-05-09 13:45:01 +00:00
* feat: handle defi account with coinstats provider * chore: refactor to follow project conventions * fix: fixing codex/coderabbit findings * fix: fixing coderabbit findings * fix: fixing coderabbit findings * fix: fixing coderabbit findings * fix: fixing coderabbit findings * fix: fixing coderabbit findings
812 lines
28 KiB
Ruby
812 lines
28 KiB
Ruby
require "test_helper"
|
|
|
|
class CoinstatsItem::ImporterTest < ActiveSupport::TestCase
|
|
setup do
|
|
@family = families(:dylan_family)
|
|
@coinstats_item = CoinstatsItem.create!(
|
|
family: @family,
|
|
name: "Test CoinStats Connection",
|
|
api_key: "test_api_key_123"
|
|
)
|
|
|
|
@mock_provider = mock("Provider::Coinstats")
|
|
# Stub DeFi endpoint globally — individual tests override if needed
|
|
@mock_provider.stubs(:get_wallet_defi).returns(success_response({ protocols: [] }))
|
|
end
|
|
|
|
# Helper to wrap data in Provider::Response
|
|
def success_response(data)
|
|
Provider::Response.new(success?: true, data: data, error: nil)
|
|
end
|
|
|
|
def error_response(message)
|
|
Provider::Response.new(success?: false, data: nil, error: Provider::Error.new(message))
|
|
end
|
|
|
|
test "returns early when no linked accounts" do
|
|
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
|
|
|
|
result = importer.import
|
|
|
|
assert result[:success]
|
|
assert_equal 0, result[:accounts_updated]
|
|
assert_equal 0, result[:transactions_imported]
|
|
end
|
|
|
|
test "updates linked accounts with balance data" do
|
|
# Create a linked coinstats account
|
|
crypto = Crypto.create!
|
|
account = @family.accounts.create!(
|
|
accountable: crypto,
|
|
name: "Test Ethereum",
|
|
balance: 1000,
|
|
currency: "USD"
|
|
)
|
|
coinstats_account = @coinstats_item.coinstats_accounts.create!(
|
|
name: "Ethereum Wallet",
|
|
currency: "USD",
|
|
raw_payload: { address: "0x123abc", blockchain: "ethereum" }
|
|
)
|
|
AccountProvider.create!(account: account, provider: coinstats_account)
|
|
|
|
# Mock balance response
|
|
balance_data = [
|
|
{ coinId: "ethereum", name: "Ethereum", symbol: "ETH", amount: 1.5, price: 2000, imgUrl: "https://example.com/eth.png" }
|
|
]
|
|
|
|
bulk_response = [
|
|
{ blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", balances: balance_data }
|
|
]
|
|
|
|
@mock_provider.expects(:get_wallet_balances)
|
|
.with("ethereum:0x123abc")
|
|
.returns(success_response(bulk_response))
|
|
|
|
@mock_provider.expects(:extract_wallet_balance)
|
|
.with(bulk_response, "0x123abc", "ethereum")
|
|
.returns(balance_data)
|
|
|
|
bulk_transactions_response = [
|
|
{ blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", transactions: [] }
|
|
]
|
|
|
|
@mock_provider.expects(:get_wallet_transactions)
|
|
.with("ethereum:0x123abc")
|
|
.returns(success_response(bulk_transactions_response))
|
|
|
|
@mock_provider.expects(:extract_wallet_transactions)
|
|
.with(bulk_transactions_response, "0x123abc", "ethereum")
|
|
.returns([])
|
|
|
|
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
|
|
result = importer.import
|
|
|
|
assert result[:success]
|
|
assert_equal 1, result[:accounts_updated]
|
|
assert_equal 0, result[:accounts_failed]
|
|
end
|
|
|
|
test "skips account when missing address or blockchain" do
|
|
# Create a linked account with missing wallet info
|
|
crypto = Crypto.create!
|
|
account = @family.accounts.create!(
|
|
accountable: crypto,
|
|
name: "Test Crypto",
|
|
balance: 1000,
|
|
currency: "USD"
|
|
)
|
|
coinstats_account = @coinstats_item.coinstats_accounts.create!(
|
|
name: "Missing Info Wallet",
|
|
currency: "USD",
|
|
raw_payload: {} # Missing address and blockchain
|
|
)
|
|
AccountProvider.create!(account: account, provider: coinstats_account)
|
|
|
|
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
|
|
result = importer.import
|
|
|
|
# The import succeeds but no accounts are updated (missing info returns success: false)
|
|
assert result[:success] # No exceptions = success
|
|
assert_equal 0, result[:accounts_updated]
|
|
assert_equal 0, result[:accounts_failed] # Doesn't count as "failed" - only exceptions do
|
|
end
|
|
|
|
test "imports transactions and merges with existing" do
|
|
# Create a linked coinstats account with existing transactions
|
|
crypto = Crypto.create!
|
|
account = @family.accounts.create!(
|
|
accountable: crypto,
|
|
name: "Test Ethereum",
|
|
balance: 1000,
|
|
currency: "USD"
|
|
)
|
|
coinstats_account = @coinstats_item.coinstats_accounts.create!(
|
|
name: "Ethereum Wallet",
|
|
currency: "USD",
|
|
account_id: "ethereum",
|
|
raw_payload: { address: "0x123abc", blockchain: "ethereum" },
|
|
raw_transactions_payload: [
|
|
{ hash: { id: "0xexisting1" }, type: "Received", date: "2025-01-01T10:00:00.000Z", transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ] }
|
|
]
|
|
)
|
|
AccountProvider.create!(account: account, provider: coinstats_account)
|
|
|
|
balance_data = [
|
|
{ coinId: "ethereum", name: "Ethereum", symbol: "ETH", amount: 2.0, price: 2500 }
|
|
]
|
|
|
|
bulk_response = [
|
|
{ blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", balances: balance_data }
|
|
]
|
|
|
|
@mock_provider.expects(:get_wallet_balances)
|
|
.with("ethereum:0x123abc")
|
|
.returns(success_response(bulk_response))
|
|
|
|
@mock_provider.expects(:extract_wallet_balance)
|
|
.with(bulk_response, "0x123abc", "ethereum")
|
|
.returns(balance_data)
|
|
|
|
new_transactions = [
|
|
{ hash: { id: "0xexisting1" }, type: "Received", date: "2025-01-01T10:00:00.000Z", transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ] }, # duplicate
|
|
{ hash: { id: "0xnew1" }, type: "Sent", date: "2025-01-02T11:00:00.000Z", transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ] } # new
|
|
]
|
|
|
|
bulk_transactions_response = [
|
|
{ blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", transactions: new_transactions }
|
|
]
|
|
|
|
@mock_provider.expects(:get_wallet_transactions)
|
|
.with("ethereum:0x123abc")
|
|
.returns(success_response(bulk_transactions_response))
|
|
|
|
@mock_provider.expects(:extract_wallet_transactions)
|
|
.with(bulk_transactions_response, "0x123abc", "ethereum")
|
|
.returns(new_transactions)
|
|
|
|
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
|
|
result = importer.import
|
|
|
|
assert result[:success]
|
|
assert_equal 1, result[:accounts_updated]
|
|
|
|
# Should have 2 transactions (1 existing + 1 new, no duplicate)
|
|
coinstats_account.reload
|
|
assert_equal 2, coinstats_account.raw_transactions_payload.count
|
|
end
|
|
|
|
test "handles rate limit error during transactions fetch gracefully" do
|
|
crypto = Crypto.create!
|
|
account = @family.accounts.create!(
|
|
accountable: crypto,
|
|
name: "Test Ethereum",
|
|
balance: 1000,
|
|
currency: "USD"
|
|
)
|
|
coinstats_account = @coinstats_item.coinstats_accounts.create!(
|
|
name: "Ethereum Wallet",
|
|
currency: "USD",
|
|
raw_payload: { address: "0x123abc", blockchain: "ethereum" }
|
|
)
|
|
AccountProvider.create!(account: account, provider: coinstats_account)
|
|
|
|
balance_data = [
|
|
{ coinId: "ethereum", name: "Ethereum", symbol: "ETH", amount: 1.0, price: 2000 }
|
|
]
|
|
|
|
bulk_response = [
|
|
{ blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", balances: balance_data }
|
|
]
|
|
|
|
@mock_provider.expects(:get_wallet_balances)
|
|
.with("ethereum:0x123abc")
|
|
.returns(success_response(bulk_response))
|
|
|
|
@mock_provider.expects(:extract_wallet_balance)
|
|
.with(bulk_response, "0x123abc", "ethereum")
|
|
.returns(balance_data)
|
|
|
|
# Bulk transaction fetch fails with error - returns error response from fetch_transactions_for_accounts
|
|
@mock_provider.expects(:get_wallet_transactions)
|
|
.with("ethereum:0x123abc")
|
|
.raises(Provider::Coinstats::Error.new("Rate limited"))
|
|
|
|
# When bulk fetch fails, extract_wallet_transactions is not called (bulk_transactions_data is nil)
|
|
|
|
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
|
|
result = importer.import
|
|
|
|
# Should still succeed since balance was updated
|
|
assert result[:success]
|
|
assert_equal 1, result[:accounts_updated]
|
|
assert_equal 0, result[:transactions_imported]
|
|
end
|
|
|
|
test "preserves exchange portfolio snapshot when portfolio coin fetch is missing" do
|
|
crypto = Crypto.create!
|
|
account = @family.accounts.create!(
|
|
accountable: crypto,
|
|
name: "Bitvavo",
|
|
balance: 250,
|
|
cash_balance: 10,
|
|
currency: "EUR"
|
|
)
|
|
|
|
coinstats_account = @coinstats_item.coinstats_accounts.create!(
|
|
name: "Bitvavo",
|
|
currency: "EUR",
|
|
account_id: "exchange_portfolio:portfolio_123",
|
|
wallet_address: "portfolio_123",
|
|
current_balance: 250,
|
|
raw_payload: {
|
|
source: "exchange",
|
|
portfolio_account: true,
|
|
portfolio_id: "portfolio_123",
|
|
connection_id: "bitvavo",
|
|
exchange_name: "Bitvavo",
|
|
coins: [
|
|
{
|
|
coin: { identifier: "bitcoin", symbol: "BTC", name: "Bitcoin" },
|
|
count: "0.003",
|
|
price: { EUR: "80000" }
|
|
},
|
|
{
|
|
coin: { identifier: "FiatCoin:eur", symbol: "EUR", name: "Euro", isFiat: true },
|
|
count: "10",
|
|
price: { EUR: "1" }
|
|
}
|
|
]
|
|
},
|
|
raw_transactions_payload: []
|
|
)
|
|
AccountProvider.create!(account: account, provider: coinstats_account)
|
|
|
|
@mock_provider.expects(:sync_exchange).with(portfolio_id: "portfolio_123").returns(success_response({}))
|
|
@mock_provider.expects(:list_exchange_transactions)
|
|
.with(portfolio_id: "portfolio_123", currency: "USD", from: nil)
|
|
.returns([])
|
|
@mock_provider.expects(:list_portfolio_coins)
|
|
.with(portfolio_id: "portfolio_123")
|
|
.returns(nil)
|
|
|
|
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
|
|
|
|
assert_no_changes -> { coinstats_account.reload.current_balance.to_f } do
|
|
result = importer.import
|
|
assert result[:success]
|
|
assert_equal 1, result[:accounts_updated]
|
|
assert_equal 0, result[:transactions_imported]
|
|
end
|
|
|
|
reloaded = coinstats_account.reload
|
|
assert_equal "portfolio_123", reloaded.raw_payload["portfolio_id"]
|
|
assert_equal 2, reloaded.raw_payload["coins"].size
|
|
assert_equal 250.0, reloaded.current_balance.to_f
|
|
end
|
|
|
|
test "writes an empty exchange portfolio snapshot when CoinStats returns an empty portfolio" do
|
|
crypto = Crypto.create!
|
|
account = @family.accounts.create!(
|
|
accountable: crypto,
|
|
name: "Bitvavo",
|
|
balance: 250,
|
|
cash_balance: 10,
|
|
currency: "EUR"
|
|
)
|
|
|
|
coinstats_account = @coinstats_item.coinstats_accounts.create!(
|
|
name: "Bitvavo",
|
|
currency: "EUR",
|
|
account_id: "exchange_portfolio:portfolio_123",
|
|
wallet_address: "portfolio_123",
|
|
current_balance: 250,
|
|
raw_payload: {
|
|
source: "exchange",
|
|
portfolio_account: true,
|
|
portfolio_id: "portfolio_123",
|
|
connection_id: "bitvavo",
|
|
exchange_name: "Bitvavo",
|
|
coins: [
|
|
{
|
|
coin: { identifier: "bitcoin", symbol: "BTC", name: "Bitcoin" },
|
|
count: "0.003",
|
|
price: { EUR: "80000" }
|
|
}
|
|
]
|
|
},
|
|
raw_transactions_payload: []
|
|
)
|
|
AccountProvider.create!(account: account, provider: coinstats_account)
|
|
|
|
@mock_provider.expects(:sync_exchange).with(portfolio_id: "portfolio_123").returns(success_response({}))
|
|
@mock_provider.expects(:list_exchange_transactions)
|
|
.with(portfolio_id: "portfolio_123", currency: "USD", from: nil)
|
|
.returns([])
|
|
@mock_provider.expects(:list_portfolio_coins)
|
|
.with(portfolio_id: "portfolio_123")
|
|
.returns([])
|
|
|
|
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
|
|
result = importer.import
|
|
|
|
assert result[:success]
|
|
assert_equal 1, result[:accounts_updated]
|
|
|
|
reloaded = coinstats_account.reload
|
|
assert_equal 0.0, reloaded.current_balance.to_f
|
|
assert_equal [], reloaded.raw_payload["coins"]
|
|
end
|
|
|
|
test "calculates balance from matching token only, not all tokens" do
|
|
# Create two accounts for different tokens in the same wallet
|
|
crypto1 = Crypto.create!
|
|
account1 = @family.accounts.create!(
|
|
accountable: crypto1,
|
|
name: "Ethereum (0xmu...ulti)",
|
|
balance: 0,
|
|
currency: "USD"
|
|
)
|
|
coinstats_account1 = @coinstats_item.coinstats_accounts.create!(
|
|
name: "Ethereum (0xmu...ulti)",
|
|
currency: "USD",
|
|
account_id: "ethereum",
|
|
raw_payload: { address: "0xmulti", blockchain: "ethereum" }
|
|
)
|
|
AccountProvider.create!(account: account1, provider: coinstats_account1)
|
|
|
|
crypto2 = Crypto.create!
|
|
account2 = @family.accounts.create!(
|
|
accountable: crypto2,
|
|
name: "Dai Stablecoin (0xmu...ulti)",
|
|
balance: 0,
|
|
currency: "USD"
|
|
)
|
|
coinstats_account2 = @coinstats_item.coinstats_accounts.create!(
|
|
name: "Dai Stablecoin (0xmu...ulti)",
|
|
currency: "USD",
|
|
account_id: "dai",
|
|
raw_payload: { address: "0xmulti", blockchain: "ethereum" }
|
|
)
|
|
AccountProvider.create!(account: account2, provider: coinstats_account2)
|
|
|
|
# Multiple tokens with different values
|
|
balance_data = [
|
|
{ coinId: "ethereum", name: "Ethereum", symbol: "ETH", amount: 2.0, price: 2000 }, # $4000
|
|
{ coinId: "dai", name: "Dai Stablecoin", symbol: "DAI", amount: 1000, price: 1 } # $1000
|
|
]
|
|
|
|
# Both accounts share the same wallet address/blockchain, so only one unique wallet
|
|
bulk_response = [
|
|
{ blockchain: "ethereum", address: "0xmulti", connectionId: "ethereum", balances: balance_data }
|
|
]
|
|
|
|
@mock_provider.expects(:get_wallet_balances)
|
|
.with("ethereum:0xmulti")
|
|
.returns(success_response(bulk_response))
|
|
|
|
@mock_provider.expects(:extract_wallet_balance)
|
|
.with(bulk_response, "0xmulti", "ethereum")
|
|
.returns(balance_data)
|
|
.twice
|
|
|
|
bulk_transactions_response = [
|
|
{ blockchain: "ethereum", address: "0xmulti", connectionId: "ethereum", transactions: [] }
|
|
]
|
|
|
|
@mock_provider.expects(:get_wallet_transactions)
|
|
.with("ethereum:0xmulti")
|
|
.returns(success_response(bulk_transactions_response))
|
|
|
|
@mock_provider.expects(:extract_wallet_transactions)
|
|
.with(bulk_transactions_response, "0xmulti", "ethereum")
|
|
.returns([])
|
|
.twice
|
|
|
|
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
|
|
importer.import
|
|
|
|
coinstats_account1.reload
|
|
coinstats_account2.reload
|
|
|
|
# Each account should have only its matching token's balance, not the total
|
|
# ETH: 2.0 * 2000 = $4000
|
|
assert_equal 4000.0, coinstats_account1.current_balance.to_f
|
|
# DAI: 1000 * 1 = $1000
|
|
assert_equal 1000.0, coinstats_account2.current_balance.to_f
|
|
end
|
|
|
|
test "handles api errors for individual accounts without failing entire import" do
|
|
crypto1 = Crypto.create!
|
|
account1 = @family.accounts.create!(
|
|
accountable: crypto1,
|
|
name: "Working Wallet",
|
|
balance: 1000,
|
|
currency: "USD"
|
|
)
|
|
coinstats_account1 = @coinstats_item.coinstats_accounts.create!(
|
|
name: "Working Wallet",
|
|
currency: "USD",
|
|
raw_payload: { address: "0xworking", blockchain: "ethereum" }
|
|
)
|
|
AccountProvider.create!(account: account1, provider: coinstats_account1)
|
|
|
|
crypto2 = Crypto.create!
|
|
account2 = @family.accounts.create!(
|
|
accountable: crypto2,
|
|
name: "Failing Wallet",
|
|
balance: 500,
|
|
currency: "USD"
|
|
)
|
|
coinstats_account2 = @coinstats_item.coinstats_accounts.create!(
|
|
name: "Failing Wallet",
|
|
currency: "USD",
|
|
raw_payload: { address: "0xfailing", blockchain: "ethereum" }
|
|
)
|
|
AccountProvider.create!(account: account2, provider: coinstats_account2)
|
|
|
|
# With multiple wallets, bulk endpoint is used
|
|
# Bulk response includes only the working wallet's data
|
|
bulk_response = [
|
|
{
|
|
blockchain: "ethereum",
|
|
address: "0xworking",
|
|
connectionId: "ethereum",
|
|
balances: [ { coinId: "ethereum", name: "Ethereum", amount: 1.0, price: 2000 } ]
|
|
}
|
|
# 0xfailing not included - simulates partial failure or missing data
|
|
]
|
|
|
|
@mock_provider.expects(:get_wallet_balances)
|
|
.with("ethereum:0xworking,ethereum:0xfailing")
|
|
.returns(success_response(bulk_response))
|
|
|
|
@mock_provider.expects(:extract_wallet_balance)
|
|
.with(bulk_response, "0xworking", "ethereum")
|
|
.returns([ { coinId: "ethereum", name: "Ethereum", amount: 1.0, price: 2000 } ])
|
|
|
|
@mock_provider.expects(:extract_wallet_balance)
|
|
.with(bulk_response, "0xfailing", "ethereum")
|
|
.returns([]) # Empty array for missing wallet
|
|
|
|
bulk_transactions_response = [
|
|
{
|
|
blockchain: "ethereum",
|
|
address: "0xworking",
|
|
connectionId: "ethereum",
|
|
transactions: []
|
|
}
|
|
]
|
|
|
|
@mock_provider.expects(:get_wallet_transactions)
|
|
.with("ethereum:0xworking,ethereum:0xfailing")
|
|
.returns(success_response(bulk_transactions_response))
|
|
|
|
@mock_provider.expects(:extract_wallet_transactions)
|
|
.with(bulk_transactions_response, "0xworking", "ethereum")
|
|
.returns([])
|
|
|
|
@mock_provider.expects(:extract_wallet_transactions)
|
|
.with(bulk_transactions_response, "0xfailing", "ethereum")
|
|
.returns([])
|
|
|
|
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
|
|
result = importer.import
|
|
|
|
assert result[:success] # Both accounts updated (one with empty balance)
|
|
assert_equal 2, result[:accounts_updated]
|
|
assert_equal 0, result[:accounts_failed]
|
|
end
|
|
|
|
test "uses bulk endpoint for multiple unique wallets and falls back on error" do
|
|
# Create accounts with two different wallet addresses
|
|
crypto1 = Crypto.create!
|
|
account1 = @family.accounts.create!(
|
|
accountable: crypto1,
|
|
name: "Ethereum Wallet",
|
|
balance: 0,
|
|
currency: "USD"
|
|
)
|
|
coinstats_account1 = @coinstats_item.coinstats_accounts.create!(
|
|
name: "Ethereum Wallet",
|
|
currency: "USD",
|
|
raw_payload: { address: "0xeth123", blockchain: "ethereum" }
|
|
)
|
|
AccountProvider.create!(account: account1, provider: coinstats_account1)
|
|
|
|
crypto2 = Crypto.create!
|
|
account2 = @family.accounts.create!(
|
|
accountable: crypto2,
|
|
name: "Bitcoin Wallet",
|
|
balance: 0,
|
|
currency: "USD"
|
|
)
|
|
coinstats_account2 = @coinstats_item.coinstats_accounts.create!(
|
|
name: "Bitcoin Wallet",
|
|
currency: "USD",
|
|
raw_payload: { address: "bc1qbtc456", blockchain: "bitcoin" }
|
|
)
|
|
AccountProvider.create!(account: account2, provider: coinstats_account2)
|
|
|
|
# Bulk endpoint returns data for both wallets
|
|
bulk_response = [
|
|
{
|
|
blockchain: "ethereum",
|
|
address: "0xeth123",
|
|
connectionId: "ethereum",
|
|
balances: [ { coinId: "ethereum", name: "Ethereum", amount: 2.0, price: 2500 } ]
|
|
},
|
|
{
|
|
blockchain: "bitcoin",
|
|
address: "bc1qbtc456",
|
|
connectionId: "bitcoin",
|
|
balances: [ { coinId: "bitcoin", name: "Bitcoin", amount: 0.1, price: 45000 } ]
|
|
}
|
|
]
|
|
|
|
@mock_provider.expects(:get_wallet_balances)
|
|
.with("ethereum:0xeth123,bitcoin:bc1qbtc456")
|
|
.returns(success_response(bulk_response))
|
|
|
|
@mock_provider.expects(:extract_wallet_balance)
|
|
.with(bulk_response, "0xeth123", "ethereum")
|
|
.returns([ { coinId: "ethereum", name: "Ethereum", amount: 2.0, price: 2500 } ])
|
|
|
|
@mock_provider.expects(:extract_wallet_balance)
|
|
.with(bulk_response, "bc1qbtc456", "bitcoin")
|
|
.returns([ { coinId: "bitcoin", name: "Bitcoin", amount: 0.1, price: 45000 } ])
|
|
|
|
bulk_transactions_response = [
|
|
{
|
|
blockchain: "ethereum",
|
|
address: "0xeth123",
|
|
connectionId: "ethereum",
|
|
transactions: []
|
|
},
|
|
{
|
|
blockchain: "bitcoin",
|
|
address: "bc1qbtc456",
|
|
connectionId: "bitcoin",
|
|
transactions: []
|
|
}
|
|
]
|
|
|
|
@mock_provider.expects(:get_wallet_transactions)
|
|
.with("ethereum:0xeth123,bitcoin:bc1qbtc456")
|
|
.returns(success_response(bulk_transactions_response))
|
|
|
|
@mock_provider.expects(:extract_wallet_transactions)
|
|
.with(bulk_transactions_response, "0xeth123", "ethereum")
|
|
.returns([])
|
|
|
|
@mock_provider.expects(:extract_wallet_transactions)
|
|
.with(bulk_transactions_response, "bc1qbtc456", "bitcoin")
|
|
.returns([])
|
|
|
|
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
|
|
result = importer.import
|
|
|
|
assert result[:success]
|
|
assert_equal 2, result[:accounts_updated]
|
|
|
|
# Verify balances were updated
|
|
coinstats_account1.reload
|
|
coinstats_account2.reload
|
|
assert_equal 5000.0, coinstats_account1.current_balance.to_f # 2.0 * 2500
|
|
assert_equal 4500.0, coinstats_account2.current_balance.to_f # 0.1 * 45000
|
|
end
|
|
|
|
# DeFi / staking tests
|
|
|
|
test "creates DeFi account with balance equal to total position value, not quantity * price" do
|
|
crypto = Crypto.create!
|
|
account = @family.accounts.create!(
|
|
accountable: crypto,
|
|
name: "Ethereum Wallet",
|
|
balance: 1000,
|
|
currency: "USD"
|
|
)
|
|
coinstats_account = @coinstats_item.coinstats_accounts.create!(
|
|
name: "Ethereum (0x12...abc)",
|
|
currency: "USD",
|
|
account_id: "ethereum",
|
|
raw_payload: { address: "0x123abc", blockchain: "ethereum" }
|
|
)
|
|
AccountProvider.create!(account: account, provider: coinstats_account)
|
|
|
|
# DeFi response: 32 ETH staked, total position value = $70,272 (= 32 * $2196)
|
|
# The `price` field is TotalValueDto (total position value), NOT price per token.
|
|
defi_response = {
|
|
protocols: [
|
|
{
|
|
id: "lido",
|
|
name: "Lido",
|
|
logo: "https://example.com/lido.png",
|
|
investments: [
|
|
{
|
|
name: "Staking",
|
|
assets: [
|
|
{
|
|
title: "Deposit",
|
|
coinId: "ethereum",
|
|
symbol: "ETH",
|
|
amount: 32.0,
|
|
price: { USD: 70272.0 } # total value, not per-token
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
@mock_provider.expects(:get_wallet_defi)
|
|
.with(address: "0x123abc", connection_id: "ethereum")
|
|
.returns(success_response(defi_response))
|
|
|
|
@mock_provider.stubs(:get_wallet_balances).returns(success_response([]))
|
|
@mock_provider.stubs(:extract_wallet_balance).returns([])
|
|
@mock_provider.stubs(:get_wallet_transactions).returns(success_response([]))
|
|
@mock_provider.stubs(:extract_wallet_transactions).returns([])
|
|
|
|
assert_difference "CoinstatsAccount.count", 1 do
|
|
assert_difference "Account.count", 1 do
|
|
CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider).import
|
|
end
|
|
end
|
|
|
|
defi_account = @coinstats_item.coinstats_accounts.find_by(account_id: "defi:ethereum:lido:staking:ethereum:deposit")
|
|
assert_not_nil defi_account
|
|
assert_equal "defi", defi_account.raw_payload["source"]
|
|
# Balance must be the total position value ($70,272), NOT 32 * $70,272
|
|
assert_equal 70272.0, defi_account.current_balance.to_f
|
|
assert_equal "ETH (Lido Staking)", defi_account.name
|
|
end
|
|
|
|
test "zeros out DeFi account when staking position is no longer active" do
|
|
crypto = Crypto.create!
|
|
account = @family.accounts.create!(
|
|
accountable: crypto,
|
|
name: "Ethereum Wallet",
|
|
balance: 1000,
|
|
currency: "USD"
|
|
)
|
|
wallet_account = @coinstats_item.coinstats_accounts.create!(
|
|
name: "Ethereum (0x12...abc)",
|
|
currency: "USD",
|
|
account_id: "ethereum",
|
|
raw_payload: { address: "0x123abc", blockchain: "ethereum" }
|
|
)
|
|
AccountProvider.create!(account: account, provider: wallet_account)
|
|
|
|
# Existing DeFi account from a previous sync
|
|
defi_crypto = Crypto.create!
|
|
defi_linked_account = @family.accounts.create!(
|
|
accountable: defi_crypto,
|
|
name: "ETH (Lido Staking)",
|
|
balance: 70272,
|
|
currency: "USD"
|
|
)
|
|
defi_account = @coinstats_item.coinstats_accounts.create!(
|
|
name: "ETH (Lido Staking)",
|
|
currency: "USD",
|
|
account_id: "defi:ethereum:lido:staking:ethereum:deposit",
|
|
wallet_address: "0x123abc",
|
|
current_balance: 70272,
|
|
raw_payload: {
|
|
source: "defi",
|
|
address: "0x123abc",
|
|
blockchain: "ethereum",
|
|
protocol_id: "lido",
|
|
amount: 32.0,
|
|
balance: 70272.0
|
|
}
|
|
)
|
|
AccountProvider.create!(account: defi_linked_account, provider: defi_account)
|
|
|
|
# DeFi response returns empty — position has been fully unstaked
|
|
@mock_provider.expects(:get_wallet_defi)
|
|
.with(address: "0x123abc", connection_id: "ethereum")
|
|
.returns(success_response({ protocols: [] }))
|
|
|
|
@mock_provider.stubs(:get_wallet_balances).returns(success_response([]))
|
|
@mock_provider.stubs(:extract_wallet_balance).returns([])
|
|
@mock_provider.stubs(:get_wallet_transactions).returns(success_response([]))
|
|
@mock_provider.stubs(:extract_wallet_transactions).returns([])
|
|
|
|
CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider).import
|
|
|
|
defi_account.reload
|
|
assert_equal 0.0, defi_account.current_balance.to_f
|
|
end
|
|
|
|
test "defi accounts are skipped in wallet update loop" do
|
|
crypto = Crypto.create!
|
|
account = @family.accounts.create!(
|
|
accountable: crypto,
|
|
name: "Ethereum Wallet",
|
|
balance: 1000,
|
|
currency: "USD"
|
|
)
|
|
wallet_account = @coinstats_item.coinstats_accounts.create!(
|
|
name: "Ethereum",
|
|
currency: "USD",
|
|
account_id: "ethereum",
|
|
raw_payload: { address: "0x123abc", blockchain: "ethereum" }
|
|
)
|
|
AccountProvider.create!(account: account, provider: wallet_account)
|
|
|
|
defi_crypto = Crypto.create!
|
|
defi_linked_account = @family.accounts.create!(
|
|
accountable: defi_crypto,
|
|
name: "ETH (Lido Staking)",
|
|
balance: 1000,
|
|
currency: "USD"
|
|
)
|
|
defi_account = @coinstats_item.coinstats_accounts.create!(
|
|
name: "ETH (Lido Staking)",
|
|
currency: "USD",
|
|
account_id: "defi:ethereum:lido:staking:ethereum:deposit",
|
|
wallet_address: "0x123abc",
|
|
current_balance: 1000,
|
|
raw_payload: {
|
|
source: "defi",
|
|
address: "0x123abc",
|
|
blockchain: "ethereum",
|
|
amount: 0.5,
|
|
balance: 1000.0
|
|
}
|
|
)
|
|
AccountProvider.create!(account: defi_linked_account, provider: defi_account)
|
|
|
|
# get_wallet_defi called once (for the one wallet), get_wallet_balances/transactions only
|
|
# called once despite two linked accounts (DeFi account excluded from wallet fetch)
|
|
@mock_provider.expects(:get_wallet_defi)
|
|
.with(address: "0x123abc", connection_id: "ethereum")
|
|
.once
|
|
.returns(success_response({ protocols: [] }))
|
|
|
|
@mock_provider.expects(:get_wallet_balances).with("ethereum:0x123abc").once
|
|
.returns(success_response([]))
|
|
@mock_provider.stubs(:extract_wallet_balance).returns([])
|
|
@mock_provider.expects(:get_wallet_transactions).with("ethereum:0x123abc").once
|
|
.returns(success_response([]))
|
|
@mock_provider.stubs(:extract_wallet_transactions).returns([])
|
|
|
|
result = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider).import
|
|
|
|
assert result[:success]
|
|
end
|
|
|
|
test "propagates DeFi sync failure into accounts_failed count" do
|
|
crypto = Crypto.create!
|
|
account = @family.accounts.create!(
|
|
accountable: crypto,
|
|
name: "Ethereum Wallet",
|
|
balance: 1000,
|
|
currency: "USD"
|
|
)
|
|
coinstats_account = @coinstats_item.coinstats_accounts.create!(
|
|
name: "Ethereum",
|
|
currency: "USD",
|
|
account_id: "ethereum",
|
|
raw_payload: { address: "0x123abc", blockchain: "ethereum" }
|
|
)
|
|
AccountProvider.create!(account: account, provider: coinstats_account)
|
|
|
|
@mock_provider.expects(:get_wallet_defi)
|
|
.with(address: "0x123abc", connection_id: "ethereum")
|
|
.raises(Provider::Coinstats::Error.new("DeFi endpoint unavailable"))
|
|
|
|
@mock_provider.stubs(:get_wallet_balances).returns(success_response([]))
|
|
@mock_provider.stubs(:extract_wallet_balance).returns([])
|
|
@mock_provider.stubs(:get_wallet_transactions).returns(success_response([]))
|
|
@mock_provider.stubs(:extract_wallet_transactions).returns([])
|
|
|
|
result = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider).import
|
|
|
|
# Wallet account still updated, but DeFi failure is counted
|
|
assert_equal 1, result[:accounts_failed]
|
|
refute result[:success]
|
|
end
|
|
end
|