Files
sure/test/models/coinstats_account_test.rb
Anas Limouri a90f9b7317 Add CoinStats exchange portfolio sync and normalize linked investment charts (#1308)
* [FEATURE] Add CoinStats exchange portfolios and normalize linked investment charts

* [BUGFIX] Fix CoinStats PR regressions

* [BUGFIX] Fix CoinStats PR review findings

* [BUGFIX] Address follow-up CoinStats PR feedback

* [REFACTO] Extract CoinStats exchange account helpers

* [BUGFIX] Batch linked CoinStats chart normalization

* [BUGFIX] Fix CoinStats processor lint

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-04-01 20:25:06 +02:00

336 lines
10 KiB
Ruby

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
# Multi-wallet tests
test "account_id is unique per coinstats_item and wallet_address" do
@coinstats_account.update!(
account_id: "ethereum",
wallet_address: "0xAAA123"
)
duplicate = @coinstats_item.coinstats_accounts.build(
name: "Duplicate Ethereum",
currency: "USD",
account_id: "ethereum",
wallet_address: "0xAAA123"
)
assert_not duplicate.valid?
assert_includes duplicate.errors[:account_id], "has already been taken"
end
test "allows same account_id with different wallet_address" do
@coinstats_account.update!(
account_id: "ethereum",
wallet_address: "0xAAA123"
)
different_wallet = @coinstats_item.coinstats_accounts.build(
name: "Different Ethereum Wallet",
currency: "USD",
account_id: "ethereum",
wallet_address: "0xBBB456"
)
assert different_wallet.valid?
end
test "allows multiple accounts with nil wallet_address for backwards compatibility" do
first_account = @coinstats_item.coinstats_accounts.create!(
name: "Legacy Account 1",
currency: "USD",
account_id: "bitcoin",
wallet_address: nil
)
second_account = @coinstats_item.coinstats_accounts.build(
name: "Legacy Account 2",
currency: "USD",
account_id: "ethereum",
wallet_address: nil
)
assert second_account.valid?
assert second_account.save
end
test "deleting one wallet does not affect other wallets with same token but different address" do
# Create two wallets with the same token (ethereum) but different addresses
wallet_a = @coinstats_item.coinstats_accounts.create!(
name: "Ethereum Wallet A",
currency: "USD",
account_id: "ethereum",
wallet_address: "0xAAA111",
current_balance: 1000.00
)
wallet_b = @coinstats_item.coinstats_accounts.create!(
name: "Ethereum Wallet B",
currency: "USD",
account_id: "ethereum",
wallet_address: "0xBBB222",
current_balance: 2000.00
)
# Verify both wallets exist
assert_equal 3, @coinstats_item.coinstats_accounts.count # includes @coinstats_account from setup
# Delete wallet A
wallet_a.destroy!
# Verify only wallet A was deleted, wallet B still exists
@coinstats_item.reload
assert_equal 2, @coinstats_item.coinstats_accounts.count
# Verify wallet B is still intact with correct data
wallet_b.reload
assert_equal "Ethereum Wallet B", wallet_b.name
assert_equal "0xBBB222", wallet_b.wallet_address
assert_equal BigDecimal("2000.00"), wallet_b.current_balance
# Verify wallet A no longer exists
assert_nil CoinstatsAccount.find_by(id: wallet_a.id)
end
test "portfolio exchange account derives total and cash balances from embedded coins" do
@family.update!(currency: "EUR")
portfolio_account = @coinstats_item.coinstats_accounts.create!(
name: "Bitvavo",
currency: "EUR",
account_id: "exchange_portfolio:test",
wallet_address: "portfolio-test",
raw_payload: {
source: "exchange",
portfolio_account: true,
portfolio_id: "portfolio-test",
exchange_name: "Bitvavo",
coins: [
{
coin: { identifier: "bitcoin", symbol: "BTC", name: "Bitcoin" },
count: "0.00335845",
price: { EUR: "57950.0491" }
},
{
coin: { identifier: "ethereum", symbol: "ETH", name: "Ethereum" },
count: "0.05580825",
price: { EUR: "1728.952252246" }
},
{
coin: { identifier: "FiatCoin:eur", symbol: "EUR", name: "Euro", isFiat: true },
count: "2.58",
price: { EUR: "1" }
}
]
}
)
assert portfolio_account.exchange_portfolio_account?
refute portfolio_account.fiat_asset?
assert_equal "EUR", portfolio_account.inferred_currency
assert_in_delta 293.69214193130284, portfolio_account.inferred_current_balance.to_f, 0.0001
assert_in_delta 2.58, portfolio_account.inferred_cash_balance.to_f, 0.0001
assert_equal 2, portfolio_account.portfolio_non_fiat_coins.size
assert_equal 1, portfolio_account.portfolio_fiat_coins.size
end
end