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
This commit is contained in:
Ethan
2026-01-07 08:59:04 -06:00
committed by GitHub
parent 42b94947bf
commit 3b4ab735b0
54 changed files with 5093 additions and 23 deletions

View File

@@ -129,4 +129,49 @@ class AccountProviderTest < ActiveSupport::TestCase
assert_equal "plaid", plaid_provider.provider_name
assert_equal "simplefin", simplefin_provider.provider_name
end
test "destroying account_provider does not destroy non-coinstats provider accounts" do
provider = AccountProvider.create!(
account: @account,
provider: @plaid_account
)
plaid_account_id = @plaid_account.id
assert PlaidAccount.exists?(plaid_account_id)
provider.destroy!
# Non-CoinStats provider accounts should remain (can enter "needs setup" state)
assert PlaidAccount.exists?(plaid_account_id)
end
test "destroying account_provider destroys coinstats provider account" do
coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats",
api_key: "test_key"
)
coinstats_account = CoinstatsAccount.create!(
coinstats_item: coinstats_item,
name: "Test Wallet",
currency: "USD",
current_balance: 1000
)
provider = AccountProvider.create!(
account: @account,
provider: coinstats_account
)
coinstats_account_id = coinstats_account.id
assert CoinstatsAccount.exists?(coinstats_account_id)
provider.destroy!
# CoinStats provider accounts should be destroyed to avoid orphaned records
assert_not CoinstatsAccount.exists?(coinstats_account_id)
end
end

View File

@@ -0,0 +1,159 @@
require "test_helper"
class CoinstatsAccount::ProcessorTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats Connection",
api_key: "test_api_key_123"
)
@crypto = Crypto.create!
@account = @family.accounts.create!(
accountable: @crypto,
name: "Test Crypto Account",
balance: 1000,
currency: "USD"
)
@coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Test Wallet",
currency: "USD",
current_balance: 2500
)
AccountProvider.create!(account: @account, provider: @coinstats_account)
end
test "skips processing when no linked account" do
# Create an unlinked coinstats account
unlinked_account = @coinstats_item.coinstats_accounts.create!(
name: "Unlinked Wallet",
currency: "USD",
current_balance: 1000
)
processor = CoinstatsAccount::Processor.new(unlinked_account)
# Should not raise, just return early
assert_nothing_raised do
processor.process
end
end
test "updates account balance from coinstats account" do
@coinstats_account.update!(current_balance: 5000.50)
processor = CoinstatsAccount::Processor.new(@coinstats_account)
processor.process
@account.reload
assert_equal BigDecimal("5000.50"), @account.balance
assert_equal BigDecimal("5000.50"), @account.cash_balance
end
test "updates account currency from coinstats account" do
@coinstats_account.update!(currency: "EUR")
processor = CoinstatsAccount::Processor.new(@coinstats_account)
processor.process
@account.reload
assert_equal "EUR", @account.currency
end
test "handles zero balance" do
@coinstats_account.update!(current_balance: 0)
processor = CoinstatsAccount::Processor.new(@coinstats_account)
processor.process
@account.reload
assert_equal BigDecimal("0"), @account.balance
end
test "handles nil balance as zero" do
@coinstats_account.update!(current_balance: nil)
processor = CoinstatsAccount::Processor.new(@coinstats_account)
processor.process
@account.reload
assert_equal BigDecimal("0"), @account.balance
end
test "processes transactions" do
@coinstats_account.update!(raw_transactions_payload: [
{
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xabc123", explorerUrl: "https://etherscan.io/tx/0xabc123" }
}
])
processor = CoinstatsAccount::Processor.new(@coinstats_account)
# Mock the transaction processor to verify it's called
CoinstatsAccount::Transactions::Processor.any_instance
.expects(:process)
.returns({ success: true, total: 1, imported: 1, failed: 0, errors: [] })
.once
processor.process
end
test "continues processing when transaction processing fails" do
@coinstats_account.update!(raw_transactions_payload: [
{ type: "Received", date: "2025-01-15T10:00:00.000Z" }
])
processor = CoinstatsAccount::Processor.new(@coinstats_account)
# Mock transaction processing to raise an error
CoinstatsAccount::Transactions::Processor.any_instance
.expects(:process)
.raises(StandardError.new("Transaction processing error"))
# Should not raise - error is caught and reported
assert_nothing_raised do
processor.process
end
# Balance should still be updated
@account.reload
assert_equal BigDecimal("2500"), @account.balance
end
test "normalizes currency codes" do
@coinstats_account.update!(currency: "usd")
processor = CoinstatsAccount::Processor.new(@coinstats_account)
processor.process
@account.reload
assert_equal "USD", @account.currency
end
test "falls back to account currency when coinstats currency is nil" do
@account.update!(currency: "GBP")
# Use update_column to bypass validation
@coinstats_account.update_column(:currency, "")
processor = CoinstatsAccount::Processor.new(@coinstats_account)
processor.process
@account.reload
# Empty currency falls through to account's existing currency
assert_equal "GBP", @account.currency
end
test "raises error when account update fails" do
# Make the account invalid by directly modifying a validation constraint
Account.any_instance.stubs(:update!).raises(ActiveRecord::RecordInvalid.new(@account))
processor = CoinstatsAccount::Processor.new(@coinstats_account)
assert_raises(ActiveRecord::RecordInvalid) do
processor.process
end
end
end

View File

@@ -0,0 +1,350 @@
require "test_helper"
class CoinstatsAccount::Transactions::ProcessorTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats Connection",
api_key: "test_api_key_123"
)
@crypto = Crypto.create!
@account = @family.accounts.create!(
accountable: @crypto,
name: "Test ETH Account",
balance: 5000,
currency: "USD"
)
@coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Ethereum Wallet",
currency: "USD",
current_balance: 5000,
account_id: "ethereum"
)
AccountProvider.create!(account: @account, provider: @coinstats_account)
end
test "returns early when no transactions payload" do
@coinstats_account.update!(raw_transactions_payload: nil)
processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
result = processor.process
assert result[:success]
assert_equal 0, result[:total]
assert_equal 0, result[:imported]
assert_equal 0, result[:failed]
assert_empty result[:errors]
end
test "processes transactions from raw_transactions_payload" do
@coinstats_account.update!(raw_transactions_payload: [
{
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xprocess1" },
transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ]
}
])
processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
assert_difference "Entry.count", 1 do
result = processor.process
assert result[:success]
assert_equal 1, result[:total]
assert_equal 1, result[:imported]
assert_equal 0, result[:failed]
end
end
test "filters transactions to only process matching coin" do
@coinstats_account.update!(raw_transactions_payload: [
{
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xmatch1" },
transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ]
},
{
type: "Received",
date: "2025-01-16T10:00:00.000Z",
coinData: { count: 100, symbol: "USDC", currentValue: 100 },
hash: { id: "0xdifferent" },
transactions: [ { items: [ { coin: { id: "usd-coin" } } ] } ]
}
])
processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
# Should only process the ETH transaction
assert_difference "Entry.count", 1 do
result = processor.process
assert result[:success]
assert_equal 1, result[:total]
end
# Verify the correct transaction was imported
entry = @account.entries.last
assert_equal "coinstats_0xmatch1", entry.external_id
end
test "handles transaction processing errors gracefully" do
@coinstats_account.update!(raw_transactions_payload: [
{
# Invalid transaction - missing required fields
type: "Received",
coinData: { count: 1.0, symbol: "ETH" },
transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ]
# Missing date and hash
}
])
processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
assert_no_difference "Entry.count" do
result = processor.process
refute result[:success]
assert_equal 1, result[:total]
assert_equal 0, result[:imported]
assert_equal 1, result[:failed]
assert_equal 1, result[:errors].count
end
end
test "processes multiple valid transactions" do
@coinstats_account.update!(raw_transactions_payload: [
{
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xmulti1" },
transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ]
},
{
type: "Sent",
date: "2025-01-16T10:00:00.000Z",
coinData: { count: -0.5, symbol: "ETH", currentValue: 1000 },
hash: { id: "0xmulti2" },
transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ]
}
])
processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
assert_difference "Entry.count", 2 do
result = processor.process
assert result[:success]
assert_equal 2, result[:total]
assert_equal 2, result[:imported]
end
end
test "matches by coin symbol in coinData as fallback" do
@coinstats_account.update!(
name: "ETH Wallet",
account_id: "ethereum",
raw_transactions_payload: [
{
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xsymbol1" }
# No transactions array with coin.id
}
]
)
processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
assert_difference "Entry.count", 1 do
result = processor.process
assert result[:success]
end
end
test "processes all transactions when no account_id set" do
@coinstats_account.update!(
account_id: nil,
raw_transactions_payload: [
{
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xnofilter1" }
},
{
type: "Received",
date: "2025-01-16T10:00:00.000Z",
coinData: { count: 100, symbol: "USDC", currentValue: 100 },
hash: { id: "0xnofilter2" }
}
]
)
processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
assert_difference "Entry.count", 2 do
result = processor.process
assert result[:success]
assert_equal 2, result[:total]
end
end
test "tracks failed transactions with errors" do
@coinstats_account.update!(
account_id: nil,
raw_transactions_payload: [
{
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xvalid1" }
},
{
# Missing date
type: "Received",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xinvalid" }
}
]
)
processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
result = processor.process
refute result[:success]
assert_equal 2, result[:total]
assert_equal 1, result[:imported]
assert_equal 1, result[:failed]
assert_equal 1, result[:errors].count
error = result[:errors].first
assert_equal "0xinvalid", error[:transaction_id]
assert_match(/Validation error/, error[:error])
end
# Tests for strict symbol matching to avoid false positives
# (e.g., "ETH" should not match "Ethereum Classic" which has symbol "ETC")
test "symbol matching does not cause false positives with similar names" do
# Ethereum Classic wallet should NOT match ETH transactions
@coinstats_account.update!(
name: "Ethereum Classic (0x1234abcd...)",
account_id: "ethereum-classic",
raw_transactions_payload: [
{
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xfalsepositive1" }
# No coin.id, relies on symbol matching fallback
}
]
)
processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
# Should NOT process - "ETH" should not match "Ethereum Classic"
assert_no_difference "Entry.count" do
result = processor.process
assert result[:success]
assert_equal 0, result[:total]
end
end
test "symbol matching works with parenthesized token format" do
@coinstats_account.update!(
name: "Ethereum (ETH)",
account_id: "ethereum",
raw_transactions_payload: [
{
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xparenthesized1" }
}
]
)
processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
assert_difference "Entry.count", 1 do
result = processor.process
assert result[:success]
end
end
test "symbol matching works with symbol as whole word in name" do
@coinstats_account.update!(
name: "ETH Wallet",
account_id: "ethereum",
raw_transactions_payload: [
{
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xwholeword1" }
}
]
)
processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
assert_difference "Entry.count", 1 do
result = processor.process
assert result[:success]
end
end
test "symbol matching does not match partial substrings" do
# WETH wallet should NOT match ETH transactions via symbol fallback
@coinstats_account.update!(
name: "WETH Wrapped Ethereum",
account_id: "weth",
raw_transactions_payload: [
{
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xpartial1" }
# No coin.id, relies on symbol matching fallback
}
]
)
processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
# Should NOT process - "ETH" is a substring of "WETH" but not a whole word match
assert_no_difference "Entry.count" do
result = processor.process
assert result[:success]
assert_equal 0, result[:total]
end
end
test "symbol matching is case insensitive" do
@coinstats_account.update!(
name: "eth wallet",
account_id: "ethereum",
raw_transactions_payload: [
{
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xcaseinsensitive1" }
}
]
)
processor = CoinstatsAccount::Transactions::Processor.new(@coinstats_account)
assert_difference "Entry.count", 1 do
result = processor.process
assert result[:success]
end
end
end

View File

@@ -0,0 +1,202 @@
require "test_helper"
class CoinstatsAccountTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats Connection",
api_key: "test_api_key_123"
)
@coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Test Wallet",
currency: "USD",
current_balance: 1000.00
)
end
test "belongs to coinstats_item" do
assert_equal @coinstats_item, @coinstats_account.coinstats_item
end
test "can have account through account_provider" do
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Linked Crypto Account",
balance: 1000,
currency: "USD"
)
AccountProvider.create!(account: account, provider: @coinstats_account)
assert_equal account, @coinstats_account.account
assert_equal account, @coinstats_account.current_account
end
test "requires name to be present" do
coinstats_account = @coinstats_item.coinstats_accounts.build(
currency: "USD"
)
coinstats_account.name = nil
assert_not coinstats_account.valid?
assert_includes coinstats_account.errors[:name], "can't be blank"
end
test "requires currency to be present" do
coinstats_account = @coinstats_item.coinstats_accounts.build(
name: "Test"
)
coinstats_account.currency = nil
assert_not coinstats_account.valid?
assert_includes coinstats_account.errors[:currency], "can't be blank"
end
test "account_id is unique per coinstats_item" do
@coinstats_account.update!(account_id: "unique_account_id_123")
duplicate = @coinstats_item.coinstats_accounts.build(
name: "Duplicate",
currency: "USD",
account_id: "unique_account_id_123"
)
assert_not duplicate.valid?
assert_includes duplicate.errors[:account_id], "has already been taken"
end
test "allows nil account_id for multiple accounts" do
second_account = @coinstats_item.coinstats_accounts.build(
name: "Second Account",
currency: "USD",
account_id: nil
)
assert second_account.valid?
end
test "upsert_coinstats_snapshot updates balance and metadata" do
snapshot = {
balance: 2500.50,
currency: "USD",
name: "Updated Wallet Name",
status: "active",
provider: "coinstats",
institution_logo: "https://example.com/logo.png"
}
@coinstats_account.upsert_coinstats_snapshot!(snapshot)
@coinstats_account.reload
assert_equal BigDecimal("2500.50"), @coinstats_account.current_balance
assert_equal "USD", @coinstats_account.currency
assert_equal "Updated Wallet Name", @coinstats_account.name
assert_equal "active", @coinstats_account.account_status
assert_equal "coinstats", @coinstats_account.provider
assert_equal({ "logo" => "https://example.com/logo.png" }, @coinstats_account.institution_metadata)
assert_equal snapshot.stringify_keys, @coinstats_account.raw_payload
end
test "upsert_coinstats_snapshot handles symbol keys" do
snapshot = {
balance: 3000.0,
currency: "USD",
name: "Symbol Key Wallet"
}
@coinstats_account.upsert_coinstats_snapshot!(snapshot)
@coinstats_account.reload
assert_equal BigDecimal("3000.0"), @coinstats_account.current_balance
assert_equal "Symbol Key Wallet", @coinstats_account.name
end
test "upsert_coinstats_snapshot handles string keys" do
snapshot = {
"balance" => 3500.0,
"currency" => "USD",
"name" => "String Key Wallet"
}
@coinstats_account.upsert_coinstats_snapshot!(snapshot)
@coinstats_account.reload
assert_equal BigDecimal("3500.0"), @coinstats_account.current_balance
assert_equal "String Key Wallet", @coinstats_account.name
end
test "upsert_coinstats_snapshot sets account_id from id if not already set" do
@coinstats_account.update!(account_id: nil)
snapshot = {
id: "new_token_id_123",
balance: 1000.0,
currency: "USD",
name: "Test"
}
@coinstats_account.upsert_coinstats_snapshot!(snapshot)
@coinstats_account.reload
assert_equal "new_token_id_123", @coinstats_account.account_id
end
test "upsert_coinstats_snapshot preserves existing account_id" do
@coinstats_account.update!(account_id: "existing_id")
snapshot = {
id: "different_id",
balance: 1000.0,
currency: "USD",
name: "Test"
}
@coinstats_account.upsert_coinstats_snapshot!(snapshot)
@coinstats_account.reload
assert_equal "existing_id", @coinstats_account.account_id
end
test "upsert_coinstats_transactions_snapshot stores transactions array" do
transactions = [
{ type: "Received", date: "2025-01-01T10:00:00.000Z", hash: { id: "0xabc" } },
{ type: "Sent", date: "2025-01-02T11:00:00.000Z", hash: { id: "0xdef" } }
]
@coinstats_account.upsert_coinstats_transactions_snapshot!(transactions)
@coinstats_account.reload
assert_equal 2, @coinstats_account.raw_transactions_payload.count
# Keys may be strings after DB round-trip
first_tx = @coinstats_account.raw_transactions_payload.first
assert_equal "0xabc", first_tx.dig("hash", "id") || first_tx.dig(:hash, :id)
end
test "upsert_coinstats_transactions_snapshot extracts result from hash response" do
response = {
meta: { page: 1, limit: 100 },
result: [
{ type: "Received", date: "2025-01-01T10:00:00.000Z", hash: { id: "0xabc" } }
]
}
@coinstats_account.upsert_coinstats_transactions_snapshot!(response)
@coinstats_account.reload
assert_equal 1, @coinstats_account.raw_transactions_payload.count
assert_equal "0xabc", @coinstats_account.raw_transactions_payload.first["hash"]["id"].to_s
end
test "upsert_coinstats_transactions_snapshot handles empty result" do
response = {
meta: { page: 1, limit: 100 },
result: []
}
@coinstats_account.upsert_coinstats_transactions_snapshot!(response)
@coinstats_account.reload
assert_equal [], @coinstats_account.raw_transactions_payload
end
end

View File

@@ -0,0 +1,267 @@
require "test_helper"
class CoinstatsEntry::ProcessorTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats Connection",
api_key: "test_api_key_123"
)
@crypto = Crypto.create!
@account = @family.accounts.create!(
accountable: @crypto,
name: "Test Crypto Account",
balance: 1000,
currency: "USD"
)
@coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Test ETH Wallet",
currency: "USD",
current_balance: 5000,
institution_metadata: { "logo" => "https://example.com/eth.png" }
)
AccountProvider.create!(account: @account, provider: @coinstats_account)
end
test "processes received transaction" do
transaction_data = {
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.5, symbol: "ETH", currentValue: 3000 },
hash: { id: "0xabc123", explorerUrl: "https://etherscan.io/tx/0xabc123" }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
assert_difference "Entry.count", 1 do
processor.process
end
entry = @account.entries.last
assert_equal "coinstats_0xabc123", entry.external_id
assert_equal BigDecimal("-3000"), entry.amount # Negative = income
assert_equal "USD", entry.currency
assert_equal Date.new(2025, 1, 15), entry.date
assert_equal "Received ETH", entry.name
end
test "processes sent transaction" do
transaction_data = {
type: "Sent",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: -0.5, symbol: "ETH", currentValue: 1000 },
hash: { id: "0xdef456", explorerUrl: "https://etherscan.io/tx/0xdef456" }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
assert_difference "Entry.count", 1 do
processor.process
end
entry = @account.entries.last
assert_equal BigDecimal("1000"), entry.amount # Positive = expense
assert_equal "Sent ETH", entry.name
end
test "stores extra metadata" do
transaction_data = {
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xmeta123", explorerUrl: "https://etherscan.io/tx/0xmeta123" },
profitLoss: { profit: 100.50, profitPercent: 5.25 },
fee: { count: 0.001, coin: { symbol: "ETH" }, totalWorth: 2.0 }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
processor.process
entry = @account.entries.last
extra = entry.transaction.extra["coinstats"]
assert_equal "0xmeta123", extra["transaction_hash"]
assert_equal "https://etherscan.io/tx/0xmeta123", extra["explorer_url"]
assert_equal "Received", extra["transaction_type"]
assert_equal "ETH", extra["symbol"]
assert_equal 1.0, extra["count"]
assert_equal 100.50, extra["profit"]
assert_equal 5.25, extra["profit_percent"]
assert_equal 0.001, extra["fee_amount"]
assert_equal "ETH", extra["fee_symbol"]
assert_equal 2.0, extra["fee_usd"]
end
test "handles UTXO transaction ID format" do
transaction_data = {
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 0.1, symbol: "BTC", currentValue: 4000 },
transactions: [
{ items: [ { id: "utxo_tx_id_123" } ] }
]
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
processor.process
entry = @account.entries.last
assert_equal "coinstats_utxo_tx_id_123", entry.external_id
end
test "generates fallback ID when no hash available" do
transaction_data = {
type: "Swap",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 100, symbol: "USDC", currentValue: 100 }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
processor.process
entry = @account.entries.last
# Fallback IDs use a hash digest format: "coinstats_fallback_<16-char-hex>"
assert_match(/^coinstats_fallback_[a-f0-9]{16}$/, entry.external_id)
end
test "raises error when transaction missing identifier" do
transaction_data = {
type: nil,
date: nil,
coinData: { count: nil }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
assert_raises(ArgumentError) do
processor.process
end
end
test "skips processing when no linked account" do
unlinked_account = @coinstats_item.coinstats_accounts.create!(
name: "Unlinked",
currency: "USD"
)
transaction_data = {
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xskip123" }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: unlinked_account)
assert_no_difference "Entry.count" do
result = processor.process
assert_nil result
end
end
test "creates notes with transaction details" do
transaction_data = {
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.5, symbol: "ETH", currentValue: 3000 },
hash: { id: "0xnotes123", explorerUrl: "https://etherscan.io/tx/0xnotes123" },
profitLoss: { profit: 150.00, profitPercent: 10.0 },
fee: { count: 0.002, coin: { symbol: "ETH" }, totalWorth: 4.0 }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
processor.process
entry = @account.entries.last
assert_includes entry.notes, "1.5 ETH"
assert_includes entry.notes, "Fee: 0.002 ETH"
assert_includes entry.notes, "P/L: $150.0 (10.0%)"
assert_includes entry.notes, "Explorer: https://etherscan.io/tx/0xnotes123"
end
test "handles integer timestamp" do
timestamp = Time.new(2025, 1, 15, 10, 0, 0).to_i
transaction_data = {
type: "Received",
date: timestamp,
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xtimestamp123" }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
processor.process
entry = @account.entries.last
assert_equal Date.new(2025, 1, 15), entry.date
end
test "raises error for missing date" do
transaction_data = {
type: "Received",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xnodate123" }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
assert_raises(ArgumentError) do
processor.process
end
end
test "builds name with symbol preferring it over coin name" do
transaction_data = {
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "WETH" },
hash: { id: "0xname123" },
profitLoss: { currentValue: 2000 },
transactions: [
{ items: [ { coin: { name: "Wrapped Ether" } } ] }
]
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
processor.process
entry = @account.entries.last
assert_equal "Received WETH", entry.name
end
test "handles swap out as outgoing transaction" do
transaction_data = {
type: "swap_out",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xswap123" }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
processor.process
entry = @account.entries.last
assert_equal BigDecimal("2000"), entry.amount # Positive = expense/outflow
end
test "is idempotent - does not duplicate transactions" do
transaction_data = {
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xidempotent123" }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
assert_difference "Entry.count", 1 do
processor.process
end
# Processing again should not create duplicate
assert_no_difference "Entry.count" do
processor.process
end
end
end

View File

@@ -0,0 +1,480 @@
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

View File

@@ -0,0 +1,177 @@
require "test_helper"
class CoinstatsItem::SyncerTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats Connection",
api_key: "test_api_key_123"
)
@syncer = CoinstatsItem::Syncer.new(@coinstats_item)
end
test "perform_sync imports data from coinstats api" do
mock_sync = mock("sync")
mock_sync.stubs(:respond_to?).with(:status_text).returns(true)
mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true)
mock_sync.stubs(:window_start_date).returns(nil)
mock_sync.stubs(:window_end_date).returns(nil)
mock_sync.expects(:update!).at_least_once
@coinstats_item.expects(:import_latest_coinstats_data).once
@syncer.perform_sync(mock_sync)
end
test "perform_sync updates pending_account_setup when unlinked accounts exist" do
# Create an unlinked coinstats account (no AccountProvider)
@coinstats_item.coinstats_accounts.create!(
name: "Unlinked Wallet",
currency: "USD"
)
mock_sync = mock("sync")
mock_sync.stubs(:respond_to?).with(:status_text).returns(true)
mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true)
mock_sync.stubs(:window_start_date).returns(nil)
mock_sync.stubs(:window_end_date).returns(nil)
mock_sync.expects(:update!).at_least_once
@coinstats_item.expects(:import_latest_coinstats_data).once
@syncer.perform_sync(mock_sync)
assert @coinstats_item.reload.pending_account_setup?
end
test "perform_sync clears pending_account_setup when all accounts linked" do
@coinstats_item.update!(pending_account_setup: true)
# Create a linked coinstats account
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Crypto",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Linked Wallet",
currency: "USD"
)
AccountProvider.create!(account: account, provider: coinstats_account)
mock_sync = mock("sync")
mock_sync.stubs(:respond_to?).with(:status_text).returns(true)
mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true)
mock_sync.stubs(:window_start_date).returns(nil)
mock_sync.stubs(:window_end_date).returns(nil)
mock_sync.expects(:update!).at_least_once
@coinstats_item.expects(:import_latest_coinstats_data).once
@coinstats_item.expects(:process_accounts).once
@coinstats_item.expects(:schedule_account_syncs).once
@syncer.perform_sync(mock_sync)
refute @coinstats_item.reload.pending_account_setup?
end
test "perform_sync processes accounts when linked accounts exist" do
# Create a linked coinstats account
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Crypto",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Linked Wallet",
currency: "USD"
)
AccountProvider.create!(account: account, provider: coinstats_account)
mock_sync = mock("sync")
mock_sync.stubs(:respond_to?).with(:status_text).returns(true)
mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true)
mock_sync.stubs(:window_start_date).returns(nil)
mock_sync.stubs(:window_end_date).returns(nil)
mock_sync.expects(:update!).at_least_once
@coinstats_item.expects(:import_latest_coinstats_data).once
@coinstats_item.expects(:process_accounts).once
@coinstats_item.expects(:schedule_account_syncs).with(
parent_sync: mock_sync,
window_start_date: nil,
window_end_date: nil
).once
@syncer.perform_sync(mock_sync)
end
test "perform_sync skips processing when no linked accounts" do
mock_sync = mock("sync")
mock_sync.stubs(:respond_to?).with(:status_text).returns(true)
mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true)
mock_sync.stubs(:window_start_date).returns(nil)
mock_sync.stubs(:window_end_date).returns(nil)
mock_sync.expects(:update!).at_least_once
@coinstats_item.expects(:import_latest_coinstats_data).once
@coinstats_item.expects(:process_accounts).never
@coinstats_item.expects(:schedule_account_syncs).never
@syncer.perform_sync(mock_sync)
end
test "perform_sync records sync stats" do
# Create one linked and one unlinked account
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Crypto",
balance: 1000,
currency: "USD"
)
linked_account = @coinstats_item.coinstats_accounts.create!(
name: "Linked Wallet",
currency: "USD"
)
AccountProvider.create!(account: account, provider: linked_account)
@coinstats_item.coinstats_accounts.create!(
name: "Unlinked Wallet",
currency: "USD"
)
recorded_stats = nil
mock_sync = mock("sync")
mock_sync.stubs(:respond_to?).with(:status_text).returns(true)
mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true)
mock_sync.stubs(:window_start_date).returns(nil)
mock_sync.stubs(:window_end_date).returns(nil)
mock_sync.expects(:update!).at_least_once.with do |args|
recorded_stats = args[:sync_stats] if args.key?(:sync_stats)
true
end
@coinstats_item.expects(:import_latest_coinstats_data).once
@coinstats_item.expects(:process_accounts).once
@coinstats_item.expects(:schedule_account_syncs).once
@syncer.perform_sync(mock_sync)
assert_equal 2, recorded_stats[:total_accounts]
assert_equal 1, recorded_stats[:linked_accounts]
assert_equal 1, recorded_stats[:unlinked_accounts]
end
test "perform_post_sync is a no-op" do
# Just ensure it doesn't raise
assert_nothing_raised do
@syncer.perform_post_sync
end
end
end

View File

@@ -0,0 +1,280 @@
require "test_helper"
class CoinstatsItem::WalletLinkerTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats Connection",
api_key: "test_api_key_123"
)
end
# Helper to wrap data in Provider::Response
def success_response(data)
Provider::Response.new(success?: true, data: data, error: nil)
end
test "link returns failure when no tokens found" do
Provider::Coinstats.any_instance.expects(:get_wallet_balances)
.with("ethereum:0x123abc")
.returns(success_response([]))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance)
.with([], "0x123abc", "ethereum")
.returns([])
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0x123abc", blockchain: "ethereum")
result = linker.link
refute result.success?
assert_equal 0, result.created_count
assert_includes result.errors, "No tokens found for wallet"
end
test "link creates account from single token" do
token_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: token_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances)
.with("ethereum:0x123abc")
.returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance)
.with(bulk_response, "0x123abc", "ethereum")
.returns(token_data)
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0x123abc", blockchain: "ethereum")
assert_difference [ "Account.count", "CoinstatsAccount.count", "AccountProvider.count" ], 1 do
result = linker.link
assert result.success?
assert_equal 1, result.created_count
assert_empty result.errors
end
# Verify the account was created correctly
coinstats_account = @coinstats_item.coinstats_accounts.last
# Note: upsert_coinstats_snapshot! overwrites name with raw token name
assert_equal "Ethereum", coinstats_account.name
assert_equal "USD", coinstats_account.currency
assert_equal 3000.0, coinstats_account.current_balance.to_f # 1.5 * 2000
account = coinstats_account.account
# Account name is set before upsert_coinstats_snapshot so it keeps the formatted name
assert_equal "Ethereum (0x12...3abc)", account.name
assert_equal 3000.0, account.balance.to_f
assert_equal "USD", account.currency
assert_equal "Crypto", account.accountable_type
end
test "link creates multiple accounts from multiple tokens" do
token_data = [
{ coinId: "ethereum", name: "Ethereum", symbol: "ETH", amount: 2.0, price: 2000 },
{ coinId: "dai", name: "Dai Stablecoin", symbol: "DAI", amount: 1000, price: 1 }
]
bulk_response = [
{ blockchain: "ethereum", address: "0xmulti", connectionId: "ethereum", balances: token_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances)
.with("ethereum:0xmulti")
.returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance)
.with(bulk_response, "0xmulti", "ethereum")
.returns(token_data)
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0xmulti", blockchain: "ethereum")
assert_difference "Account.count", 2 do
assert_difference "CoinstatsAccount.count", 2 do
result = linker.link
assert result.success?
assert_equal 2, result.created_count
end
end
end
test "link triggers sync after creating accounts" do
token_data = [
{ coinId: "ethereum", name: "Ethereum", amount: 1.0, price: 2000 }
]
bulk_response = [
{ blockchain: "ethereum", address: "0x123", connectionId: "ethereum", balances: token_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns(token_data)
@coinstats_item.expects(:sync_later).once
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0x123", blockchain: "ethereum")
linker.link
end
test "link does not trigger sync when no accounts created" do
Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response([]))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns([])
@coinstats_item.expects(:sync_later).never
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0x123", blockchain: "ethereum")
linker.link
end
test "link stores wallet metadata in raw_payload" do
token_data = [
{
coinId: "ethereum",
name: "Ethereum",
symbol: "ETH",
amount: 1.0,
price: 2000,
imgUrl: "https://example.com/eth.png"
}
]
bulk_response = [
{ blockchain: "ethereum", address: "0xtest123", connectionId: "ethereum", balances: token_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances)
.with("ethereum:0xtest123")
.returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance)
.with(bulk_response, "0xtest123", "ethereum")
.returns(token_data)
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0xtest123", blockchain: "ethereum")
linker.link
coinstats_account = @coinstats_item.coinstats_accounts.last
raw_payload = coinstats_account.raw_payload
assert_equal "0xtest123", raw_payload["address"]
assert_equal "ethereum", raw_payload["blockchain"]
assert_equal "https://example.com/eth.png", raw_payload["institution_logo"]
end
test "link handles account creation errors gracefully" do
token_data = [
{ coinId: "ethereum", name: "Ethereum", amount: 1.0, price: 2000 },
{ coinId: "bad", name: nil, amount: 1.0, price: 100 } # Will fail validation
]
bulk_response = [
{ blockchain: "ethereum", address: "0xtest", connectionId: "ethereum", balances: token_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns(token_data)
# We need to mock the error scenario - name can't be blank
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0xtest", blockchain: "ethereum")
result = linker.link
# Should create the valid account but have errors for the invalid one
assert result.success? # At least one succeeded
assert result.created_count >= 1
end
test "link builds correct account name with address suffix" do
token_data = [
{ coinId: "ethereum", name: "Ethereum", amount: 1.0, price: 2000 }
]
bulk_response = [
{ blockchain: "ethereum", address: "0xABCDEF123456", connectionId: "ethereum", balances: token_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns(token_data)
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0xABCDEF123456", blockchain: "ethereum")
linker.link
# Account name includes the address suffix (created before upsert_coinstats_snapshot)
account = @coinstats_item.accounts.last
assert_equal "Ethereum (0xAB...3456)", account.name
end
test "link handles single token as hash instead of array" do
token_data = {
coinId: "bitcoin",
name: "Bitcoin",
symbol: "BTC",
amount: 0.5,
price: 40000
}
bulk_response = [
{ blockchain: "bitcoin", address: "bc1qtest", connectionId: "bitcoin", balances: [ token_data ] }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns(token_data)
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "bc1qtest", blockchain: "bitcoin")
assert_difference "Account.count", 1 do
result = linker.link
assert result.success?
end
account = @coinstats_item.coinstats_accounts.last
assert_equal 20000.0, account.current_balance.to_f # 0.5 * 40000
end
test "link stores correct account_id from token" do
token_data = [
{ coinId: "unique_token_123", name: "My Token", amount: 100, price: 1 }
]
bulk_response = [
{ blockchain: "ethereum", address: "0xtest", connectionId: "ethereum", balances: token_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns(token_data)
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0xtest", blockchain: "ethereum")
linker.link
coinstats_account = @coinstats_item.coinstats_accounts.last
assert_equal "unique_token_123", coinstats_account.account_id
end
test "link falls back to id field for account_id" do
token_data = [
{ id: "fallback_id_456", name: "Fallback Token", amount: 50, price: 2 }
]
bulk_response = [
{ blockchain: "ethereum", address: "0xtest", connectionId: "ethereum", balances: token_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns(token_data)
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0xtest", blockchain: "ethereum")
linker.link
coinstats_account = @coinstats_item.coinstats_accounts.last
assert_equal "fallback_id_456", coinstats_account.account_id
end
end

View File

@@ -0,0 +1,231 @@
require "test_helper"
class CoinstatsItemTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats Connection",
api_key: "test_api_key_123"
)
end
test "belongs to family" do
assert_equal @family, @coinstats_item.family
end
test "has many coinstats_accounts" do
account = @coinstats_item.coinstats_accounts.create!(
name: "Test Wallet",
currency: "USD",
current_balance: 1000.00
)
assert_includes @coinstats_item.coinstats_accounts, account
end
test "has good status by default" do
assert_equal "good", @coinstats_item.status
end
test "can be marked for deletion" do
refute @coinstats_item.scheduled_for_deletion?
@coinstats_item.destroy_later
assert @coinstats_item.scheduled_for_deletion?
end
test "is syncable" do
assert_respond_to @coinstats_item, :sync_later
assert_respond_to @coinstats_item, :syncing?
end
test "requires name to be present" do
coinstats_item = CoinstatsItem.new(family: @family, api_key: "key")
coinstats_item.name = nil
assert_not coinstats_item.valid?
assert_includes coinstats_item.errors[:name], "can't be blank"
end
test "requires api_key to be present" do
coinstats_item = CoinstatsItem.new(family: @family, name: "Test")
coinstats_item.api_key = nil
assert_not coinstats_item.valid?
assert_includes coinstats_item.errors[:api_key], "can't be blank"
end
test "requires api_key to be present on update" do
@coinstats_item.api_key = ""
assert_not @coinstats_item.valid?
assert_includes @coinstats_item.errors[:api_key], "can't be blank"
end
test "scopes work correctly" do
# Create one for deletion
item_for_deletion = CoinstatsItem.create!(
family: @family,
name: "Delete Me",
api_key: "delete_key",
scheduled_for_deletion: true
)
active_items = CoinstatsItem.active
ordered_items = CoinstatsItem.ordered
assert_includes active_items, @coinstats_item
refute_includes active_items, item_for_deletion
assert_equal [ @coinstats_item, item_for_deletion ].sort_by(&:created_at).reverse,
ordered_items.to_a
end
test "needs_update scope returns items requiring update" do
@coinstats_item.update!(status: :requires_update)
good_item = CoinstatsItem.create!(
family: @family,
name: "Good Item",
api_key: "good_key"
)
needs_update_items = CoinstatsItem.needs_update
assert_includes needs_update_items, @coinstats_item
refute_includes needs_update_items, good_item
end
test "institution display name returns name when present" do
assert_equal "Test CoinStats Connection", @coinstats_item.institution_display_name
end
test "institution display name falls back to CoinStats" do
# Bypass validation by using update_column
@coinstats_item.update_column(:name, "")
assert_equal "CoinStats", @coinstats_item.institution_display_name
end
test "credentials_configured? returns true when api_key present" do
assert @coinstats_item.credentials_configured?
end
test "credentials_configured? returns false when api_key blank" do
@coinstats_item.api_key = nil
refute @coinstats_item.credentials_configured?
end
test "upserts coinstats snapshot" do
snapshot_data = {
total_balance: 5000.0,
wallets: [ { address: "0x123", blockchain: "ethereum" } ]
}
@coinstats_item.upsert_coinstats_snapshot!(snapshot_data)
@coinstats_item.reload
# Verify key data is stored correctly (keys may be string or symbol)
assert_equal 5000.0, @coinstats_item.raw_payload["total_balance"]
assert_equal 1, @coinstats_item.raw_payload["wallets"].count
assert_equal "0x123", @coinstats_item.raw_payload["wallets"].first["address"]
end
test "has_completed_initial_setup? returns false when no accounts" do
refute @coinstats_item.has_completed_initial_setup?
end
test "has_completed_initial_setup? returns true when accounts exist" do
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Crypto",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Test Wallet",
currency: "USD"
)
AccountProvider.create!(account: account, provider: coinstats_account)
assert @coinstats_item.has_completed_initial_setup?
end
test "linked_accounts_count returns count of accounts with provider links" do
# Initially no linked accounts
assert_equal 0, @coinstats_item.linked_accounts_count
# Create a linked account
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Crypto",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Test Wallet",
currency: "USD"
)
AccountProvider.create!(account: account, provider: coinstats_account)
assert_equal 1, @coinstats_item.linked_accounts_count
end
test "unlinked_accounts_count returns count of accounts without provider links" do
# Create an unlinked account
@coinstats_item.coinstats_accounts.create!(
name: "Unlinked Wallet",
currency: "USD"
)
assert_equal 1, @coinstats_item.unlinked_accounts_count
end
test "sync_status_summary shows no accounts message" do
assert_equal "No crypto wallets found", @coinstats_item.sync_status_summary
end
test "sync_status_summary shows all synced message" do
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Crypto",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Test Wallet",
currency: "USD"
)
AccountProvider.create!(account: account, provider: coinstats_account)
assert_equal "1 crypto wallet synced", @coinstats_item.sync_status_summary
end
test "sync_status_summary shows mixed status message" do
# Create a linked account
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Crypto",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Linked Wallet",
currency: "USD"
)
AccountProvider.create!(account: account, provider: coinstats_account)
# Create an unlinked account
@coinstats_item.coinstats_accounts.create!(
name: "Unlinked Wallet",
currency: "USD"
)
assert_equal "1 crypto wallets synced, 1 need setup", @coinstats_item.sync_status_summary
end
end

View File

@@ -0,0 +1,124 @@
require "test_helper"
class Provider::CoinstatsAdapterTest < ActiveSupport::TestCase
include ProviderAdapterTestInterface
setup do
@family = families(:dylan_family)
@coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats Bank",
api_key: "test_api_key_123"
)
@coinstats_account = CoinstatsAccount.create!(
coinstats_item: @coinstats_item,
name: "CoinStats Crypto Account",
account_id: "cs_mock_1",
currency: "USD",
current_balance: 1000,
institution_metadata: {
"name" => "CoinStats Test Wallet",
"domain" => "coinstats.app",
"url" => "https://coinstats.app",
"logo" => "https://example.com/logo.png"
}
)
@account = accounts(:crypto)
@adapter = Provider::CoinstatsAdapter.new(@coinstats_account, account: @account)
end
def adapter
@adapter
end
# Run shared interface tests
test_provider_adapter_interface
test_syncable_interface
test_institution_metadata_interface
# Provider-specific tests
test "returns correct provider name" do
assert_equal "coinstats", @adapter.provider_name
end
test "returns correct provider type" do
assert_equal "CoinstatsAccount", @adapter.provider_type
end
test "returns coinstats item" do
assert_equal @coinstats_account.coinstats_item, @adapter.item
end
test "returns account" do
assert_equal @account, @adapter.account
end
test "can_delete_holdings? returns false" do
assert_equal false, @adapter.can_delete_holdings?
end
test "parses institution domain from institution_metadata" do
assert_equal "coinstats.app", @adapter.institution_domain
end
test "parses institution name from institution_metadata" do
assert_equal "CoinStats Test Wallet", @adapter.institution_name
end
test "parses institution url from institution_metadata" do
assert_equal "https://coinstats.app", @adapter.institution_url
end
test "returns logo_url from institution_metadata" do
assert_equal "https://example.com/logo.png", @adapter.logo_url
end
test "derives domain from url if domain is blank" do
@coinstats_account.update!(institution_metadata: {
"url" => "https://www.example.com/path"
})
adapter = Provider::CoinstatsAdapter.new(@coinstats_account, account: @account)
assert_equal "example.com", adapter.institution_domain
end
test "supported_account_types includes Crypto" do
assert_includes Provider::CoinstatsAdapter.supported_account_types, "Crypto"
end
test "connection_configs returns configurations when family can connect" do
@family.stubs(:can_connect_coinstats?).returns(true)
configs = Provider::CoinstatsAdapter.connection_configs(family: @family)
assert_equal 1, configs.length
assert_equal "coinstats", configs.first[:key]
assert_equal "CoinStats", configs.first[:name]
assert configs.first[:can_connect]
end
test "connection_configs returns empty when family cannot connect" do
@family.stubs(:can_connect_coinstats?).returns(false)
configs = Provider::CoinstatsAdapter.connection_configs(family: @family)
assert_equal [], configs
end
test "build_provider returns nil when family is nil" do
result = Provider::CoinstatsAdapter.build_provider(family: nil)
assert_nil result
end
test "build_provider returns nil when no coinstats_items with api_key" do
empty_family = families(:empty)
result = Provider::CoinstatsAdapter.build_provider(family: empty_family)
assert_nil result
end
test "build_provider returns Provider::Coinstats when credentials configured" do
result = Provider::CoinstatsAdapter.build_provider(family: @family)
assert_instance_of Provider::Coinstats, result
end
end

View File

@@ -0,0 +1,164 @@
require "test_helper"
class Provider::CoinstatsTest < ActiveSupport::TestCase
setup do
@provider = Provider::Coinstats.new("test_api_key")
end
test "extract_wallet_balance finds matching wallet by address and connectionId" do
bulk_data = [
{
blockchain: "ethereum",
address: "0x123abc",
connectionId: "ethereum",
balances: [
{ coinId: "ethereum", name: "Ethereum", amount: 1.5, price: 2000 }
]
},
{
blockchain: "bitcoin",
address: "bc1qxyz",
connectionId: "bitcoin",
balances: [
{ coinId: "bitcoin", name: "Bitcoin", amount: 0.5, price: 50000 }
]
}
]
result = @provider.extract_wallet_balance(bulk_data, "0x123abc", "ethereum")
assert_equal 1, result.size
assert_equal "ethereum", result.first[:coinId]
end
test "extract_wallet_balance handles case insensitive matching" do
bulk_data = [
{
blockchain: "Ethereum",
address: "0x123ABC",
connectionId: "Ethereum",
balances: [
{ coinId: "ethereum", name: "Ethereum", amount: 1.5, price: 2000 }
]
}
]
result = @provider.extract_wallet_balance(bulk_data, "0x123abc", "ethereum")
assert_equal 1, result.size
assert_equal "ethereum", result.first[:coinId]
end
test "extract_wallet_balance returns empty array when wallet not found" do
bulk_data = [
{
blockchain: "ethereum",
address: "0x123abc",
connectionId: "ethereum",
balances: [
{ coinId: "ethereum", name: "Ethereum", amount: 1.5, price: 2000 }
]
}
]
result = @provider.extract_wallet_balance(bulk_data, "0xnotfound", "ethereum")
assert_equal [], result
end
test "extract_wallet_balance returns empty array for nil bulk_data" do
result = @provider.extract_wallet_balance(nil, "0x123abc", "ethereum")
assert_equal [], result
end
test "extract_wallet_balance returns empty array for non-array bulk_data" do
result = @provider.extract_wallet_balance({ error: "invalid" }, "0x123abc", "ethereum")
assert_equal [], result
end
test "extract_wallet_balance matches by blockchain when connectionId differs" do
bulk_data = [
{
blockchain: "ethereum",
address: "0x123abc",
connectionId: "eth-mainnet", # Different connectionId
balances: [
{ coinId: "ethereum", name: "Ethereum", amount: 1.5, price: 2000 }
]
}
]
result = @provider.extract_wallet_balance(bulk_data, "0x123abc", "ethereum")
assert_equal 1, result.size
end
test "extract_wallet_transactions finds matching wallet transactions" do
bulk_data = [
{
blockchain: "ethereum",
address: "0x123abc",
connectionId: "ethereum",
transactions: [
{ hash: { id: "0xtx1" }, type: "Received", date: "2025-01-01T10:00:00.000Z" },
{ hash: { id: "0xtx2" }, type: "Sent", date: "2025-01-02T11:00:00.000Z" }
]
},
{
blockchain: "bitcoin",
address: "bc1qxyz",
connectionId: "bitcoin",
transactions: [
{ hash: { id: "btctx1" }, type: "Received", date: "2025-01-03T12:00:00.000Z" }
]
}
]
result = @provider.extract_wallet_transactions(bulk_data, "0x123abc", "ethereum")
assert_equal 2, result.size
assert_equal "0xtx1", result.first[:hash][:id]
end
test "extract_wallet_transactions returns empty array when wallet not found" do
bulk_data = [
{
blockchain: "ethereum",
address: "0x123abc",
connectionId: "ethereum",
transactions: [
{ hash: { id: "0xtx1" }, type: "Received" }
]
}
]
result = @provider.extract_wallet_transactions(bulk_data, "0xnotfound", "ethereum")
assert_equal [], result
end
test "extract_wallet_transactions returns empty array for nil bulk_data" do
result = @provider.extract_wallet_transactions(nil, "0x123abc", "ethereum")
assert_equal [], result
end
test "extract_wallet_transactions handles case insensitive matching" do
bulk_data = [
{
blockchain: "Ethereum",
address: "0x123ABC",
connectionId: "Ethereum",
transactions: [
{ hash: { id: "0xtx1" }, type: "Received" }
]
}
]
result = @provider.extract_wallet_transactions(bulk_data, "0x123abc", "ethereum")
assert_equal 1, result.size
end
end