diff --git a/test/fixtures/snaptrade_items.yml b/test/fixtures/snaptrade_items.yml index d8201a91e..f1c0dfc70 100644 --- a/test/fixtures/snaptrade_items.yml +++ b/test/fixtures/snaptrade_items.yml @@ -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 diff --git a/test/models/snaptrade_account_processor_test.rb b/test/models/snaptrade_account_processor_test.rb new file mode 100644 index 000000000..c2127c0e2 --- /dev/null +++ b/test/models/snaptrade_account_processor_test.rb @@ -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 diff --git a/test/models/snaptrade_data_helpers_test.rb b/test/models/snaptrade_data_helpers_test.rb new file mode 100644 index 000000000..86eead9a1 --- /dev/null +++ b/test/models/snaptrade_data_helpers_test.rb @@ -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