mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 03:54:08 +00:00
Add comprehensive tests for SnaptradeAccount processors and data helpers. (#742)
Co-authored-by: luckyPipewrench <luckypipewrench@proton.me>
This commit is contained in:
10
test/fixtures/snaptrade_items.yml
vendored
10
test/fixtures/snaptrade_items.yml
vendored
@@ -12,9 +12,15 @@ configured_item:
|
||||
scheduled_for_deletion: false
|
||||
pending_account_setup: false
|
||||
|
||||
unconfigured_item:
|
||||
# Item with credentials but not yet registered with SnapTrade
|
||||
# (user_id and user_secret are blank - represents state after saving credentials
|
||||
# but before connecting to SnapTrade portal)
|
||||
pending_registration_item:
|
||||
family: empty
|
||||
name: "Pending Setup"
|
||||
name: "Pending Registration"
|
||||
client_id: "pending_client_id"
|
||||
consumer_key: "pending_consumer_key"
|
||||
# snaptrade_user_id and snaptrade_user_secret intentionally blank
|
||||
status: good
|
||||
scheduled_for_deletion: false
|
||||
pending_account_setup: true
|
||||
|
||||
293
test/models/snaptrade_account_processor_test.rb
Normal file
293
test/models/snaptrade_account_processor_test.rb
Normal file
@@ -0,0 +1,293 @@
|
||||
require "test_helper"
|
||||
|
||||
class SnaptradeAccountProcessorTest < ActiveSupport::TestCase
|
||||
fixtures :families, :snaptrade_items, :snaptrade_accounts, :accounts, :securities
|
||||
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@snaptrade_item = snaptrade_items(:configured_item)
|
||||
@snaptrade_account = snaptrade_accounts(:fidelity_401k)
|
||||
|
||||
# Create and link a Sure investment account
|
||||
@account = @family.accounts.create!(
|
||||
name: "Test Investment",
|
||||
balance: 50000,
|
||||
cash_balance: 1500,
|
||||
currency: "USD",
|
||||
accountable: Investment.new
|
||||
)
|
||||
@snaptrade_account.ensure_account_provider!(@account)
|
||||
@snaptrade_account.reload
|
||||
end
|
||||
|
||||
# === HoldingsProcessor Tests ===
|
||||
|
||||
test "holdings processor creates holdings from raw payload" do
|
||||
security = securities(:aapl)
|
||||
|
||||
@snaptrade_account.update!(
|
||||
raw_holdings_payload: [
|
||||
{
|
||||
"symbol" => {
|
||||
"symbol" => { "symbol" => security.ticker, "description" => security.name }
|
||||
},
|
||||
"units" => "100.5",
|
||||
"price" => "150.25",
|
||||
"currency" => "USD"
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
processor = SnaptradeAccount::HoldingsProcessor.new(@snaptrade_account)
|
||||
processor.process
|
||||
|
||||
holding = @account.holdings.find_by(security: security)
|
||||
assert_not_nil holding
|
||||
assert_equal BigDecimal("100.5"), holding.qty
|
||||
assert_equal BigDecimal("150.25"), holding.price
|
||||
end
|
||||
|
||||
test "holdings processor stores cost basis when available" do
|
||||
security = securities(:aapl)
|
||||
|
||||
@snaptrade_account.update!(
|
||||
raw_holdings_payload: [
|
||||
{
|
||||
"symbol" => {
|
||||
"symbol" => { "symbol" => security.ticker, "description" => security.name }
|
||||
},
|
||||
"units" => "50",
|
||||
"price" => "175.00",
|
||||
"average_purchase_price" => "125.50",
|
||||
"currency" => "USD"
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
processor = SnaptradeAccount::HoldingsProcessor.new(@snaptrade_account)
|
||||
processor.process
|
||||
|
||||
holding = @account.holdings.find_by(security: security)
|
||||
assert_not_nil holding
|
||||
assert_equal BigDecimal("125.50"), holding.cost_basis
|
||||
assert_equal "provider", holding.cost_basis_source
|
||||
end
|
||||
|
||||
test "holdings processor does not overwrite manual cost basis" do
|
||||
security = securities(:aapl)
|
||||
|
||||
# Create holding with manual cost basis
|
||||
holding = @account.holdings.create!(
|
||||
security: security,
|
||||
date: Date.current,
|
||||
currency: "USD",
|
||||
qty: 50,
|
||||
price: 175.00,
|
||||
amount: 8750.00,
|
||||
cost_basis: 100.00,
|
||||
cost_basis_source: "manual"
|
||||
)
|
||||
|
||||
@snaptrade_account.update!(
|
||||
raw_holdings_payload: [
|
||||
{
|
||||
"symbol" => {
|
||||
"symbol" => { "symbol" => security.ticker }
|
||||
},
|
||||
"units" => "50",
|
||||
"price" => "175.00",
|
||||
"average_purchase_price" => "125.50",
|
||||
"currency" => "USD"
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
processor = SnaptradeAccount::HoldingsProcessor.new(@snaptrade_account)
|
||||
processor.process
|
||||
|
||||
holding.reload
|
||||
assert_equal BigDecimal("100.00"), holding.cost_basis
|
||||
assert_equal "manual", holding.cost_basis_source
|
||||
end
|
||||
|
||||
test "holdings processor skips entries without ticker" do
|
||||
@snaptrade_account.update!(
|
||||
raw_holdings_payload: [
|
||||
{
|
||||
"symbol" => { "symbol" => {} }, # Missing ticker
|
||||
"units" => "100",
|
||||
"price" => "50.00"
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
processor = SnaptradeAccount::HoldingsProcessor.new(@snaptrade_account)
|
||||
|
||||
assert_nothing_raised do
|
||||
processor.process
|
||||
end
|
||||
assert_equal 0, @account.holdings.count
|
||||
end
|
||||
|
||||
# === ActivitiesProcessor Tests ===
|
||||
|
||||
test "activities processor maps BUY type to Buy label" do
|
||||
security = securities(:aapl)
|
||||
|
||||
@snaptrade_account.update!(
|
||||
raw_activities_payload: [
|
||||
{
|
||||
"id" => "activity_buy_1",
|
||||
"type" => "BUY",
|
||||
"symbol" => { "symbol" => security.ticker, "description" => security.name },
|
||||
"units" => "10",
|
||||
"price" => "150.00",
|
||||
"amount" => "1500.00",
|
||||
"settlement_date" => Date.current.to_s,
|
||||
"currency" => "USD"
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account)
|
||||
result = processor.process
|
||||
|
||||
assert_equal 1, result[:trades]
|
||||
trade_entry = @account.entries.find_by(external_id: "activity_buy_1")
|
||||
assert_not_nil trade_entry
|
||||
assert_equal "Buy", trade_entry.entryable.investment_activity_label
|
||||
end
|
||||
|
||||
test "activities processor maps SELL type with negative quantity" do
|
||||
security = securities(:aapl)
|
||||
|
||||
@snaptrade_account.update!(
|
||||
raw_activities_payload: [
|
||||
{
|
||||
"id" => "activity_sell_1",
|
||||
"type" => "SELL",
|
||||
"symbol" => { "symbol" => security.ticker },
|
||||
"units" => "5",
|
||||
"price" => "175.00",
|
||||
"amount" => "875.00",
|
||||
"settlement_date" => Date.current.to_s,
|
||||
"currency" => "USD"
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account)
|
||||
result = processor.process
|
||||
|
||||
assert_equal 1, result[:trades]
|
||||
trade_entry = @account.entries.find_by(external_id: "activity_sell_1")
|
||||
assert trade_entry.entryable.qty.negative?
|
||||
assert_equal "Sell", trade_entry.entryable.investment_activity_label
|
||||
end
|
||||
|
||||
test "activities processor handles DIVIDEND as cash transaction" do
|
||||
@snaptrade_account.update!(
|
||||
raw_activities_payload: [
|
||||
{
|
||||
"id" => "activity_div_1",
|
||||
"type" => "DIVIDEND",
|
||||
"symbol" => { "symbol" => "AAPL" },
|
||||
"amount" => "25.50",
|
||||
"settlement_date" => Date.current.to_s,
|
||||
"currency" => "USD",
|
||||
"description" => "AAPL Dividend Payment"
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account)
|
||||
result = processor.process
|
||||
|
||||
assert_equal 1, result[:transactions]
|
||||
tx_entry = @account.entries.find_by(external_id: "activity_div_1")
|
||||
assert_not_nil tx_entry
|
||||
assert_equal "Transaction", tx_entry.entryable_type
|
||||
assert_equal "Dividend", tx_entry.entryable.investment_activity_label
|
||||
end
|
||||
|
||||
test "activities processor normalizes withdrawal as negative amount" do
|
||||
@snaptrade_account.update!(
|
||||
raw_activities_payload: [
|
||||
{
|
||||
"id" => "activity_withdraw_1",
|
||||
"type" => "WITHDRAWAL",
|
||||
"amount" => "1000.00", # Provider sends positive
|
||||
"settlement_date" => Date.current.to_s,
|
||||
"currency" => "USD"
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account)
|
||||
result = processor.process
|
||||
|
||||
assert_equal 1, result[:transactions]
|
||||
tx_entry = @account.entries.find_by(external_id: "activity_withdraw_1")
|
||||
assert tx_entry.amount.negative?
|
||||
end
|
||||
|
||||
test "activities processor skips activities without external_id" do
|
||||
@snaptrade_account.update!(
|
||||
raw_activities_payload: [
|
||||
{
|
||||
"type" => "DIVIDEND",
|
||||
"amount" => "50.00"
|
||||
# Missing "id" field
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account)
|
||||
result = processor.process
|
||||
|
||||
assert_equal 0, result[:transactions]
|
||||
assert_equal 0, result[:trades]
|
||||
end
|
||||
|
||||
test "activities processor handles unmapped types as Other" do
|
||||
@snaptrade_account.update!(
|
||||
raw_activities_payload: [
|
||||
{
|
||||
"id" => "activity_unknown_1",
|
||||
"type" => "UNKNOWN_TYPE_XYZ",
|
||||
"amount" => "100.00",
|
||||
"settlement_date" => Date.current.to_s,
|
||||
"currency" => "USD"
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account)
|
||||
result = processor.process
|
||||
|
||||
assert_equal 1, result[:transactions]
|
||||
tx_entry = @account.entries.find_by(external_id: "activity_unknown_1")
|
||||
assert_equal "Other", tx_entry.entryable.investment_activity_label
|
||||
end
|
||||
|
||||
test "activities processor is idempotent with same external_id" do
|
||||
@snaptrade_account.update!(
|
||||
raw_activities_payload: [
|
||||
{
|
||||
"id" => "activity_idempotent_1",
|
||||
"type" => "DIVIDEND",
|
||||
"amount" => "75.00",
|
||||
"settlement_date" => Date.current.to_s,
|
||||
"currency" => "USD"
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
processor = SnaptradeAccount::ActivitiesProcessor.new(@snaptrade_account)
|
||||
processor.process
|
||||
processor.process # Process again
|
||||
|
||||
entries = @account.entries.where(external_id: "activity_idempotent_1")
|
||||
assert_equal 1, entries.count
|
||||
end
|
||||
end
|
||||
187
test/models/snaptrade_data_helpers_test.rb
Normal file
187
test/models/snaptrade_data_helpers_test.rb
Normal file
@@ -0,0 +1,187 @@
|
||||
require "test_helper"
|
||||
|
||||
class SnaptradeDataHelpersTest < ActiveSupport::TestCase
|
||||
# Create a test class that includes the concern
|
||||
class TestHelper
|
||||
include SnaptradeAccount::DataHelpers
|
||||
|
||||
# Expose private methods for testing
|
||||
def test_parse_decimal(value)
|
||||
parse_decimal(value)
|
||||
end
|
||||
|
||||
def test_parse_date(value)
|
||||
parse_date(value)
|
||||
end
|
||||
|
||||
def test_resolve_security(symbol, symbol_data)
|
||||
resolve_security(symbol, symbol_data)
|
||||
end
|
||||
|
||||
def test_extract_currency(data, symbol_data = {}, fallback = nil)
|
||||
extract_currency(data, symbol_data, fallback)
|
||||
end
|
||||
end
|
||||
|
||||
setup do
|
||||
@helper = TestHelper.new
|
||||
end
|
||||
|
||||
# === parse_decimal tests ===
|
||||
|
||||
test "parse_decimal handles BigDecimal" do
|
||||
result = @helper.test_parse_decimal(BigDecimal("123.45"))
|
||||
assert_equal BigDecimal("123.45"), result
|
||||
end
|
||||
|
||||
test "parse_decimal handles String" do
|
||||
result = @helper.test_parse_decimal("456.78")
|
||||
assert_equal BigDecimal("456.78"), result
|
||||
end
|
||||
|
||||
test "parse_decimal handles Integer" do
|
||||
result = @helper.test_parse_decimal(100)
|
||||
assert_equal BigDecimal("100"), result
|
||||
end
|
||||
|
||||
test "parse_decimal handles Float" do
|
||||
result = @helper.test_parse_decimal(99.99)
|
||||
assert_equal BigDecimal("99.99"), result
|
||||
end
|
||||
|
||||
test "parse_decimal returns nil for nil input" do
|
||||
result = @helper.test_parse_decimal(nil)
|
||||
assert_nil result
|
||||
end
|
||||
|
||||
test "parse_decimal returns nil for invalid string" do
|
||||
result = @helper.test_parse_decimal("not_a_number")
|
||||
assert_nil result
|
||||
end
|
||||
|
||||
# === parse_date tests ===
|
||||
|
||||
test "parse_date handles Date object" do
|
||||
date = Date.new(2024, 6, 15)
|
||||
result = @helper.test_parse_date(date)
|
||||
assert_equal date, result
|
||||
end
|
||||
|
||||
test "parse_date handles ISO string" do
|
||||
result = @helper.test_parse_date("2024-06-15")
|
||||
assert_equal Date.new(2024, 6, 15), result
|
||||
end
|
||||
|
||||
test "parse_date handles Time object" do
|
||||
time = Time.zone.parse("2024-06-15 10:30:00")
|
||||
result = @helper.test_parse_date(time)
|
||||
assert_equal Date.new(2024, 6, 15), result
|
||||
end
|
||||
|
||||
test "parse_date handles DateTime" do
|
||||
dt = DateTime.new(2024, 6, 15, 10, 30)
|
||||
result = @helper.test_parse_date(dt)
|
||||
# DateTime is a subclass of Date, so it matches Date branch and returns as-is
|
||||
# which is acceptable behavior - the result is still usable as a date
|
||||
assert result.respond_to?(:year)
|
||||
assert_equal 2024, result.year
|
||||
assert_equal 6, result.month
|
||||
assert_equal 15, result.day
|
||||
end
|
||||
|
||||
test "parse_date returns nil for nil input" do
|
||||
result = @helper.test_parse_date(nil)
|
||||
assert_nil result
|
||||
end
|
||||
|
||||
test "parse_date returns nil for invalid string" do
|
||||
result = @helper.test_parse_date("invalid_date")
|
||||
assert_nil result
|
||||
end
|
||||
|
||||
# === resolve_security tests ===
|
||||
|
||||
test "resolve_security finds existing security by ticker" do
|
||||
existing = Security.create!(ticker: "TEST", name: "Test Company")
|
||||
|
||||
result = @helper.test_resolve_security("TEST", {})
|
||||
assert_equal existing, result
|
||||
end
|
||||
|
||||
test "resolve_security creates new security when not found" do
|
||||
symbol_data = { "description" => "New Corp Inc" }
|
||||
|
||||
result = @helper.test_resolve_security("NEWCORP", symbol_data)
|
||||
|
||||
assert_not_nil result
|
||||
assert_equal "NEWCORP", result.ticker
|
||||
assert_equal "New Corp Inc", result.name
|
||||
end
|
||||
|
||||
test "resolve_security uppercases ticker" do
|
||||
symbol_data = { "description" => "Lowercase Test" }
|
||||
|
||||
result = @helper.test_resolve_security("lowercase", symbol_data)
|
||||
|
||||
assert_equal "LOWERCASE", result.ticker
|
||||
end
|
||||
|
||||
test "resolve_security returns nil for blank ticker" do
|
||||
result = @helper.test_resolve_security("", {})
|
||||
assert_nil result
|
||||
|
||||
result = @helper.test_resolve_security(nil, {})
|
||||
assert_nil result
|
||||
end
|
||||
|
||||
test "resolve_security handles race condition on creation" do
|
||||
# Simulate race condition by creating after first check
|
||||
symbol_data = { "description" => "Race Condition Test" }
|
||||
|
||||
# Create the security before resolve_security can
|
||||
Security.create!(ticker: "RACECOND", name: "Already Created")
|
||||
|
||||
# Should return existing instead of raising
|
||||
result = @helper.test_resolve_security("RACECOND", symbol_data)
|
||||
assert_equal "RACECOND", result.ticker
|
||||
end
|
||||
|
||||
# === extract_currency tests ===
|
||||
|
||||
test "extract_currency handles hash with code key (symbol access)" do
|
||||
data = { currency: { code: "CAD" } }
|
||||
result = @helper.test_extract_currency(data)
|
||||
assert_equal "CAD", result
|
||||
end
|
||||
|
||||
test "extract_currency handles hash with code key (string access)" do
|
||||
data = { "currency" => { "code" => "EUR" } }
|
||||
result = @helper.test_extract_currency(data)
|
||||
assert_equal "EUR", result
|
||||
end
|
||||
|
||||
test "extract_currency handles string currency" do
|
||||
data = { currency: "GBP" }
|
||||
result = @helper.test_extract_currency(data)
|
||||
assert_equal "GBP", result
|
||||
end
|
||||
|
||||
test "extract_currency falls back to symbol_data" do
|
||||
data = {}
|
||||
symbol_data = { currency: "JPY" }
|
||||
result = @helper.test_extract_currency(data, symbol_data)
|
||||
assert_equal "JPY", result
|
||||
end
|
||||
|
||||
test "extract_currency uses fallback when no currency found" do
|
||||
data = {}
|
||||
result = @helper.test_extract_currency(data, {}, "USD")
|
||||
assert_equal "USD", result
|
||||
end
|
||||
|
||||
test "extract_currency returns nil when no currency and no fallback" do
|
||||
data = {}
|
||||
result = @helper.test_extract_currency(data, {}, nil)
|
||||
assert_nil result
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user