Files
sure/test/models/coinstats_item/importer_test.rb
Ethan 3b4ab735b0 Add (beta) CoinStats Crypto Wallet Integration with Balance and Transaction Syncing (#512)
* Feat(CoinStats): Scaffold implementation, not yet functional

* Feat(CoinStats): Implement crypto wallet balance and transactions

* Feat(CoinStats): Add tests, Minor improvements

* Feat(CoinStats): Utilize bulk fetch API endpoints

* Feat(CoinStats): Migrate strings to i8n

* Feat(CoinStats): Fix error handling in wallet link modal

* Feat(CoinStats): Implement hourly provider sync job

* Feat(CoinStats): Generate docstrings

* Fix(CoinStats): Validate API Key on provider update

* Fix(Providers): Safely handle race condition in merchance creation

* Fix(CoinStats): Don't catch system signals in account processor

* Fix(CoinStats): Preload before iterating accounts

* Fix(CoinStats): Add no opener / referrer to API dashboard link

* Fix(CoinStats): Use strict matching for symbols

* Fix(CoinStats): Remove dead code in transactions importer

* Fix(CoinStats): Avoid transaction fallback ID collisions

* Fix(CoinStats): Improve Blockchains fetch error handling

* Fix(CoinStats): Enforce NOT NULL constraint for API Key schema

* Fix(CoinStats): Migrate sync status strings to i8n

* Fix(CoinStats): Use class name rather than hardcoded string

* Fix(CoinStats): Use account currency rather than hardcoded USD

* Fix(CoinStats): Migrate from standalone to Provider class

* Fix(CoinStats): Fix test failures due to string changes
2026-01-07 15:59:04 +01:00

481 lines
16 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")
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 "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
end