mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 12:04:08 +00:00
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:
@@ -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
|
||||
|
||||
159
test/models/coinstats_account/processor_test.rb
Normal file
159
test/models/coinstats_account/processor_test.rb
Normal 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
|
||||
350
test/models/coinstats_account/transactions/processor_test.rb
Normal file
350
test/models/coinstats_account/transactions/processor_test.rb
Normal 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
|
||||
202
test/models/coinstats_account_test.rb
Normal file
202
test/models/coinstats_account_test.rb
Normal 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
|
||||
267
test/models/coinstats_entry/processor_test.rb
Normal file
267
test/models/coinstats_entry/processor_test.rb
Normal 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
|
||||
480
test/models/coinstats_item/importer_test.rb
Normal file
480
test/models/coinstats_item/importer_test.rb
Normal 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
|
||||
177
test/models/coinstats_item/syncer_test.rb
Normal file
177
test/models/coinstats_item/syncer_test.rb
Normal 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
|
||||
280
test/models/coinstats_item/wallet_linker_test.rb
Normal file
280
test/models/coinstats_item/wallet_linker_test.rb
Normal 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
|
||||
231
test/models/coinstats_item_test.rb
Normal file
231
test/models/coinstats_item_test.rb
Normal 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
|
||||
124
test/models/provider/coinstats_adapter_test.rb
Normal file
124
test/models/provider/coinstats_adapter_test.rb
Normal 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
|
||||
164
test/models/provider/coinstats_test.rb
Normal file
164
test/models/provider/coinstats_test.rb
Normal 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
|
||||
Reference in New Issue
Block a user