mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 20:14:08 +00:00
Add Coinbase exchange integration with CDP API support (#704)
* **Add Coinbase integration with item and account management** - Creates migrations for `coinbase_items` and `coinbase_accounts`. - Adds models, controllers, views, and background tasks to support account linking, syncing, and transaction handling. - Implements Coinbase API client and adapter for seamless integration. - Supports ActiveRecord encryption for secure credential storage. - Adds UI components for provider setup, account management, and synchronization. * Localize Coinbase-related UI strings, refine account linking for security, and add timeouts to Coinbase API requests. * Localize Coinbase account handling to support native currencies (USD, EUR, GBP, etc.) across balances, trades, holdings, and transactions. * Improve Coinbase processing with timezone-safe parsing, native currency support, and immediate holdings updates. * Improve trend percentage formatting and enhance race condition handling for Coinbase account linking. * Fix log message wording for orphan cleanup * Ensure `selected_accounts` parameter is sanitized by rejecting blank entries. * Add tests for Coinbase integration: account, item, and controller coverage - Adds unit tests for `CoinbaseAccount` and `CoinbaseItem` models. - Adds integration tests for `CoinbaseItemsController`. - Introduces Stimulus `select-all` controller for UI checkbox handling. - Localizes UI strings and logging for Coinbase integration. * Update test fixtures to use consistent placeholder API keys and secrets * Refine `coinbase_item` tests to ensure deterministic ordering and improve scope assertions. * Integrate `SyncStats::Collector` into Coinbase syncer to streamline statistics collection and enhance consistency. * Localize Coinbase sync status messages and improve sync summary test coverage. * Update `CoinbaseItem` encryption: use deterministic encryption for `api_key` and standard for `api_secret`. * fix schema drift * Beta labels to lower expectations --------- Co-authored-by: luckyPipewrench <luckypipewrench@proton.me> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
131
test/models/coinbase_account_test.rb
Normal file
131
test/models/coinbase_account_test.rb
Normal file
@@ -0,0 +1,131 @@
|
||||
require "test_helper"
|
||||
|
||||
class CoinbaseAccountTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@coinbase_item = CoinbaseItem.create!(
|
||||
family: @family,
|
||||
name: "Test Coinbase",
|
||||
api_key: "test_key",
|
||||
api_secret: "test_secret"
|
||||
)
|
||||
@coinbase_account = @coinbase_item.coinbase_accounts.create!(
|
||||
name: "Bitcoin Wallet",
|
||||
account_id: "cb_btc_123",
|
||||
currency: "BTC",
|
||||
current_balance: 0.5
|
||||
)
|
||||
end
|
||||
|
||||
test "belongs to coinbase_item" do
|
||||
assert_equal @coinbase_item, @coinbase_account.coinbase_item
|
||||
end
|
||||
|
||||
test "validates presence of name" do
|
||||
account = CoinbaseAccount.new(coinbase_item: @coinbase_item, currency: "BTC")
|
||||
assert_not account.valid?
|
||||
assert_includes account.errors[:name], "can't be blank"
|
||||
end
|
||||
|
||||
test "validates presence of currency" do
|
||||
account = CoinbaseAccount.new(coinbase_item: @coinbase_item, name: "Test")
|
||||
assert_not account.valid?
|
||||
assert_includes account.errors[:currency], "can't be blank"
|
||||
end
|
||||
|
||||
test "upsert_coinbase_snapshot! updates account data" do
|
||||
snapshot = {
|
||||
"id" => "new_account_id",
|
||||
"name" => "Updated Wallet",
|
||||
"balance" => 1.5,
|
||||
"status" => "active",
|
||||
"currency" => "BTC"
|
||||
}
|
||||
|
||||
@coinbase_account.upsert_coinbase_snapshot!(snapshot)
|
||||
|
||||
assert_equal "new_account_id", @coinbase_account.account_id
|
||||
assert_equal "Updated Wallet", @coinbase_account.name
|
||||
assert_equal 1.5, @coinbase_account.current_balance
|
||||
assert_equal "active", @coinbase_account.account_status
|
||||
end
|
||||
|
||||
test "upsert_coinbase_transactions_snapshot! stores transaction data" do
|
||||
transactions = {
|
||||
"transactions" => [
|
||||
{ "id" => "tx1", "type" => "buy", "amount" => { "amount" => "0.1", "currency" => "BTC" } }
|
||||
]
|
||||
}
|
||||
|
||||
@coinbase_account.upsert_coinbase_transactions_snapshot!(transactions)
|
||||
|
||||
assert_equal transactions, @coinbase_account.raw_transactions_payload
|
||||
end
|
||||
|
||||
test "current_account returns nil when no account_provider exists" do
|
||||
assert_nil @coinbase_account.current_account
|
||||
end
|
||||
|
||||
test "current_account returns linked account when account_provider exists" do
|
||||
account = Account.create!(
|
||||
family: @family,
|
||||
name: "Coinbase BTC",
|
||||
balance: 50000,
|
||||
currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
AccountProvider.create!(account: account, provider: @coinbase_account)
|
||||
|
||||
# Reload to pick up the association
|
||||
@coinbase_account.reload
|
||||
|
||||
assert_equal account, @coinbase_account.current_account
|
||||
end
|
||||
|
||||
test "ensure_account_provider! creates provider link" do
|
||||
account = Account.create!(
|
||||
family: @family,
|
||||
name: "Coinbase BTC",
|
||||
balance: 50000,
|
||||
currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
|
||||
assert_difference "AccountProvider.count", 1 do
|
||||
@coinbase_account.ensure_account_provider!(account)
|
||||
end
|
||||
|
||||
@coinbase_account.reload
|
||||
assert_equal account, @coinbase_account.current_account
|
||||
end
|
||||
|
||||
test "ensure_account_provider! updates existing link" do
|
||||
account1 = Account.create!(
|
||||
family: @family,
|
||||
name: "Coinbase BTC 1",
|
||||
balance: 50000,
|
||||
currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
account2 = Account.create!(
|
||||
family: @family,
|
||||
name: "Coinbase BTC 2",
|
||||
balance: 60000,
|
||||
currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
|
||||
@coinbase_account.ensure_account_provider!(account1)
|
||||
@coinbase_account.reload
|
||||
|
||||
assert_equal account1, @coinbase_account.current_account
|
||||
|
||||
# Now link to a different account
|
||||
assert_no_difference "AccountProvider.count" do
|
||||
@coinbase_account.ensure_account_provider!(account2)
|
||||
end
|
||||
|
||||
@coinbase_account.reload
|
||||
assert_equal account2, @coinbase_account.current_account
|
||||
end
|
||||
end
|
||||
227
test/models/coinbase_item_test.rb
Normal file
227
test/models/coinbase_item_test.rb
Normal file
@@ -0,0 +1,227 @@
|
||||
require "test_helper"
|
||||
|
||||
class CoinbaseItemTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@coinbase_item = CoinbaseItem.create!(
|
||||
family: @family,
|
||||
name: "Test Coinbase Connection",
|
||||
api_key: "test_key",
|
||||
api_secret: "test_secret"
|
||||
)
|
||||
end
|
||||
|
||||
test "belongs to family" do
|
||||
assert_equal @family, @coinbase_item.family
|
||||
end
|
||||
|
||||
test "has many coinbase_accounts" do
|
||||
account = @coinbase_item.coinbase_accounts.create!(
|
||||
name: "Bitcoin Wallet",
|
||||
account_id: "test_btc_123",
|
||||
currency: "BTC",
|
||||
current_balance: 0.5
|
||||
)
|
||||
|
||||
assert_includes @coinbase_item.coinbase_accounts, account
|
||||
end
|
||||
|
||||
test "has good status by default" do
|
||||
assert_equal "good", @coinbase_item.status
|
||||
end
|
||||
|
||||
test "validates presence of name" do
|
||||
item = CoinbaseItem.new(family: @family, api_key: "key", api_secret: "secret")
|
||||
assert_not item.valid?
|
||||
assert_includes item.errors[:name], "can't be blank"
|
||||
end
|
||||
|
||||
test "validates presence of api_key" do
|
||||
item = CoinbaseItem.new(family: @family, name: "Test", api_secret: "secret")
|
||||
assert_not item.valid?
|
||||
assert_includes item.errors[:api_key], "can't be blank"
|
||||
end
|
||||
|
||||
test "validates presence of api_secret" do
|
||||
item = CoinbaseItem.new(family: @family, name: "Test", api_key: "key")
|
||||
assert_not item.valid?
|
||||
assert_includes item.errors[:api_secret], "can't be blank"
|
||||
end
|
||||
|
||||
test "can be marked for deletion" do
|
||||
refute @coinbase_item.scheduled_for_deletion?
|
||||
|
||||
@coinbase_item.destroy_later
|
||||
|
||||
assert @coinbase_item.scheduled_for_deletion?
|
||||
end
|
||||
|
||||
test "is syncable" do
|
||||
assert_respond_to @coinbase_item, :sync_later
|
||||
assert_respond_to @coinbase_item, :syncing?
|
||||
end
|
||||
|
||||
test "scopes work correctly" do
|
||||
# Use explicit timestamp to ensure deterministic ordering
|
||||
item_for_deletion = CoinbaseItem.create!(
|
||||
family: @family,
|
||||
name: "Delete Me",
|
||||
api_key: "test_key",
|
||||
api_secret: "test_secret",
|
||||
scheduled_for_deletion: true,
|
||||
created_at: 1.day.ago
|
||||
)
|
||||
|
||||
active_items = @family.coinbase_items.active
|
||||
ordered_items = @family.coinbase_items.ordered
|
||||
|
||||
assert_includes active_items, @coinbase_item
|
||||
refute_includes active_items, item_for_deletion
|
||||
|
||||
# ordered scope sorts by created_at desc, so newer (@coinbase_item) comes first
|
||||
assert_equal [ @coinbase_item, item_for_deletion ], ordered_items.to_a
|
||||
end
|
||||
|
||||
test "credentials_configured? returns true when both keys present" do
|
||||
assert @coinbase_item.credentials_configured?
|
||||
end
|
||||
|
||||
test "credentials_configured? returns false when keys missing" do
|
||||
@coinbase_item.api_key = nil
|
||||
refute @coinbase_item.credentials_configured?
|
||||
|
||||
@coinbase_item.api_key = "key"
|
||||
@coinbase_item.api_secret = nil
|
||||
refute @coinbase_item.credentials_configured?
|
||||
end
|
||||
|
||||
test "set_coinbase_institution_defaults! sets metadata" do
|
||||
@coinbase_item.set_coinbase_institution_defaults!
|
||||
|
||||
assert_equal "Coinbase", @coinbase_item.institution_name
|
||||
assert_equal "coinbase.com", @coinbase_item.institution_domain
|
||||
assert_equal "https://www.coinbase.com", @coinbase_item.institution_url
|
||||
assert_equal "#0052FF", @coinbase_item.institution_color
|
||||
end
|
||||
|
||||
test "linked_accounts_count returns count of accounts with providers" do
|
||||
coinbase_account = @coinbase_item.coinbase_accounts.create!(
|
||||
name: "BTC Wallet",
|
||||
account_id: "btc_123",
|
||||
currency: "BTC",
|
||||
current_balance: 1.0
|
||||
)
|
||||
|
||||
assert_equal 0, @coinbase_item.linked_accounts_count
|
||||
|
||||
account = Account.create!(
|
||||
family: @family,
|
||||
name: "Coinbase BTC",
|
||||
balance: 50000,
|
||||
currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
AccountProvider.create!(account: account, provider: coinbase_account)
|
||||
|
||||
assert_equal 1, @coinbase_item.linked_accounts_count
|
||||
end
|
||||
|
||||
test "unlinked_accounts_count returns count of accounts without providers" do
|
||||
@coinbase_item.coinbase_accounts.create!(
|
||||
name: "BTC Wallet",
|
||||
account_id: "btc_123",
|
||||
currency: "BTC",
|
||||
current_balance: 1.0
|
||||
)
|
||||
|
||||
assert_equal 1, @coinbase_item.unlinked_accounts_count
|
||||
end
|
||||
|
||||
test "sync_status_summary with no accounts" do
|
||||
assert_equal I18n.t("coinbase_items.coinbase_item.sync_status.no_accounts"), @coinbase_item.sync_status_summary
|
||||
end
|
||||
|
||||
test "sync_status_summary with one linked account" do
|
||||
coinbase_account = @coinbase_item.coinbase_accounts.create!(
|
||||
name: "BTC Wallet",
|
||||
account_id: "btc_123",
|
||||
currency: "BTC",
|
||||
current_balance: 1.0
|
||||
)
|
||||
|
||||
account = Account.create!(
|
||||
family: @family,
|
||||
name: "Coinbase BTC",
|
||||
balance: 50000,
|
||||
currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
AccountProvider.create!(account: account, provider: coinbase_account)
|
||||
|
||||
assert_equal I18n.t("coinbase_items.coinbase_item.sync_status.all_synced", count: 1), @coinbase_item.sync_status_summary
|
||||
end
|
||||
|
||||
test "sync_status_summary with multiple linked accounts" do
|
||||
# Create first account
|
||||
coinbase_account1 = @coinbase_item.coinbase_accounts.create!(
|
||||
name: "BTC Wallet",
|
||||
account_id: "btc_123",
|
||||
currency: "BTC",
|
||||
current_balance: 1.0
|
||||
)
|
||||
account1 = Account.create!(
|
||||
family: @family,
|
||||
name: "Coinbase BTC",
|
||||
balance: 50000,
|
||||
currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
AccountProvider.create!(account: account1, provider: coinbase_account1)
|
||||
|
||||
# Create second account
|
||||
coinbase_account2 = @coinbase_item.coinbase_accounts.create!(
|
||||
name: "ETH Wallet",
|
||||
account_id: "eth_456",
|
||||
currency: "ETH",
|
||||
current_balance: 10.0
|
||||
)
|
||||
account2 = Account.create!(
|
||||
family: @family,
|
||||
name: "Coinbase ETH",
|
||||
balance: 25000,
|
||||
currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
AccountProvider.create!(account: account2, provider: coinbase_account2)
|
||||
|
||||
assert_equal I18n.t("coinbase_items.coinbase_item.sync_status.all_synced", count: 2), @coinbase_item.sync_status_summary
|
||||
end
|
||||
|
||||
test "sync_status_summary with partial setup" do
|
||||
# Create linked account
|
||||
coinbase_account1 = @coinbase_item.coinbase_accounts.create!(
|
||||
name: "BTC Wallet",
|
||||
account_id: "btc_123",
|
||||
currency: "BTC",
|
||||
current_balance: 1.0
|
||||
)
|
||||
account = Account.create!(
|
||||
family: @family,
|
||||
name: "Coinbase BTC",
|
||||
balance: 50000,
|
||||
currency: "USD",
|
||||
accountable: Crypto.create!(subtype: "exchange")
|
||||
)
|
||||
AccountProvider.create!(account: account, provider: coinbase_account1)
|
||||
|
||||
# Create unlinked account
|
||||
@coinbase_item.coinbase_accounts.create!(
|
||||
name: "ETH Wallet",
|
||||
account_id: "eth_456",
|
||||
currency: "ETH",
|
||||
current_balance: 10.0
|
||||
)
|
||||
|
||||
assert_equal I18n.t("coinbase_items.coinbase_item.sync_status.partial_sync", linked_count: 1, unlinked_count: 1), @coinbase_item.sync_status_summary
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user