mirror of
https://github.com/we-promise/sure.git
synced 2026-04-09 15:24:48 +00:00
* 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
351 lines
10 KiB
Ruby
351 lines
10 KiB
Ruby
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
|