Merge remote-tracking branch 'upstream/main' into sso-upgrades

# Conflicts:
#	app/views/simplefin_items/_simplefin_item.html.erb
#	db/schema.rb
This commit is contained in:
Josh Waldrep
2026-01-10 11:57:23 -05:00
301 changed files with 20707 additions and 967 deletions

View File

@@ -0,0 +1,206 @@
require "test_helper"
class Api::V1::ImportsControllerTest < ActionDispatch::IntegrationTest
setup do
@family = families(:dylan_family)
@user = users(:family_admin)
@account = accounts(:depository)
@import = imports(:transaction)
@token = valid_token_for(@user)
end
test "should list imports" do
get api_v1_imports_url, headers: { Authorization: "Bearer #{@token}" }
assert_response :success
json_response = JSON.parse(response.body)
assert_not_empty json_response["data"]
assert_equal @family.imports.count, json_response["meta"]["total_count"]
end
test "should show import" do
get api_v1_import_url(@import), headers: { Authorization: "Bearer #{@token}" }
assert_response :success
json_response = JSON.parse(response.body)
assert_equal @import.id, json_response["data"]["id"]
assert_equal @import.status, json_response["data"]["status"]
end
test "should create import with raw content" do
csv_content = "date,amount,name\n2023-01-01,-10.00,Test Transaction"
assert_difference("Import.count") do
post api_v1_imports_url,
params: {
raw_file_content: csv_content,
date_col_label: "date",
amount_col_label: "amount",
name_col_label: "name",
account_id: @account.id
},
headers: { Authorization: "Bearer #{@token}" }
end
assert_response :created
json_response = JSON.parse(response.body)
assert_equal "pending", json_response["data"]["status"]
created_import = Import.find(json_response["data"]["id"])
assert_equal csv_content, created_import.raw_file_str
end
test "should create import and generate rows when configured" do
csv_content = "date,amount,name\n2023-01-01,-10.00,Test Transaction"
assert_difference([ "Import.count", "Import::Row.count" ], 1) do
post api_v1_imports_url,
params: {
raw_file_content: csv_content,
date_col_label: "date",
amount_col_label: "amount",
name_col_label: "name",
account_id: @account.id
},
headers: { Authorization: "Bearer #{@token}" }
end
assert_response :created
json_response = JSON.parse(response.body)
import = Import.find(json_response["data"]["id"])
assert_equal 1, import.rows_count
assert_equal "Test Transaction", import.rows.first.name
assert_equal "-10.00", import.rows.first.amount # Normalized
end
test "should create import and auto-publish when configured and requested" do
csv_content = "date,amount,name\n2023-01-01,-10.00,Test Transaction"
assert_enqueued_with(job: ImportJob) do
post api_v1_imports_url,
params: {
raw_file_content: csv_content,
date_col_label: "date",
amount_col_label: "amount",
name_col_label: "name",
account_id: @account.id,
date_format: "%Y-%m-%d",
publish: "true"
},
headers: { Authorization: "Bearer #{@token}" }
end
assert_response :created
json_response = JSON.parse(response.body)
assert_equal "importing", json_response["data"]["status"]
end
test "should not create import for account in another family" do
other_family = Family.create!(name: "Other Family", currency: "USD", locale: "en")
other_depository = Depository.create!(subtype: "checking")
other_account = Account.create!(family: other_family, name: "Other Account", currency: "USD", classification: "asset", accountable: other_depository, balance: 0)
csv_content = "date,amount,name\n2023-01-01,-10.00,Test Transaction"
post api_v1_imports_url,
params: {
raw_file_content: csv_content,
account_id: other_account.id
},
headers: { Authorization: "Bearer #{@token}" }
assert_response :unprocessable_entity
json_response = JSON.parse(response.body)
assert_includes json_response["errors"], "Account must belong to your family"
end
test "should reject file upload exceeding max size" do
large_file = Rack::Test::UploadedFile.new(
StringIO.new("x" * (Import::MAX_CSV_SIZE + 1)),
"text/csv",
original_filename: "large.csv"
)
assert_no_difference("Import.count") do
post api_v1_imports_url,
params: { file: large_file },
headers: { Authorization: "Bearer #{@token}" }
end
assert_response :unprocessable_entity
json_response = JSON.parse(response.body)
assert_equal "file_too_large", json_response["error"]
end
test "should reject file upload with invalid mime type" do
invalid_file = Rack::Test::UploadedFile.new(
StringIO.new("not a csv"),
"application/pdf",
original_filename: "document.pdf"
)
assert_no_difference("Import.count") do
post api_v1_imports_url,
params: { file: invalid_file },
headers: { Authorization: "Bearer #{@token}" }
end
assert_response :unprocessable_entity
json_response = JSON.parse(response.body)
assert_equal "invalid_file_type", json_response["error"]
end
test "should reject raw content exceeding max size" do
# Use a small test limit to avoid Rack request size limits
test_limit = 1.kilobyte
large_content = "x" * (test_limit + 1)
original_value = Import::MAX_CSV_SIZE
Import.send(:remove_const, :MAX_CSV_SIZE)
Import.const_set(:MAX_CSV_SIZE, test_limit)
assert_no_difference("Import.count") do
post api_v1_imports_url,
params: { raw_file_content: large_content },
headers: { Authorization: "Bearer #{@token}" }
end
assert_response :unprocessable_entity
json_response = JSON.parse(response.body)
assert_equal "content_too_large", json_response["error"]
ensure
Import.send(:remove_const, :MAX_CSV_SIZE)
Import.const_set(:MAX_CSV_SIZE, original_value)
end
test "should accept file upload with valid csv mime type" do
csv_content = "date,amount,name\n2023-01-01,-10.00,Test Transaction"
valid_file = Rack::Test::UploadedFile.new(
StringIO.new(csv_content),
"text/csv",
original_filename: "transactions.csv"
)
assert_difference("Import.count") do
post api_v1_imports_url,
params: {
file: valid_file,
date_col_label: "date",
amount_col_label: "amount",
name_col_label: "name",
account_id: @account.id
},
headers: { Authorization: "Bearer #{@token}" }
end
assert_response :created
end
private
def valid_token_for(user)
application = Doorkeeper::Application.create!(name: "Test App", redirect_uri: "urn:ietf:wg:oauth:2.0:oob", scopes: "read read_write")
Doorkeeper::AccessToken.create!(application: application, resource_owner_id: user.id, scopes: "read read_write").token
end
end

View File

@@ -84,7 +84,8 @@ class CategoriesControllerTest < ActionDispatch::IntegrationTest
end
test "bootstrap" do
assert_difference "Category.count", 19 do
# 22 default categories minus 2 that already exist in fixtures (Income, Food & Drink)
assert_difference "Category.count", 20 do
post bootstrap_categories_url
end

View File

@@ -0,0 +1,178 @@
require "test_helper"
class CoinstatsItemsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
@family = families(:dylan_family)
@coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats Connection",
api_key: "test_api_key_123"
)
end
# Helper to wrap data in Provider::Response
def success_response(data)
Provider::Response.new(success?: true, data: data, error: nil)
end
def error_response(message)
Provider::Response.new(success?: false, data: nil, error: Provider::Error.new(message))
end
test "should get new" do
get new_coinstats_item_url
assert_response :success
end
test "should create coinstats item with valid api key" do
# Mock the API key validation
Provider::Coinstats.any_instance.expects(:get_blockchains).returns(success_response([])).once
assert_difference("CoinstatsItem.count", 1) do
post coinstats_items_url, params: {
coinstats_item: {
name: "New CoinStats Connection",
api_key: "valid_api_key"
}
}
end
end
test "should not create coinstats item with invalid api key" do
# Mock the API key validation to fail
Provider::Coinstats.any_instance.expects(:get_blockchains)
.returns(error_response("Invalid API key"))
assert_no_difference("CoinstatsItem.count") do
post coinstats_items_url, params: {
coinstats_item: {
name: "New CoinStats Connection",
api_key: "invalid_api_key"
}
}
end
end
test "should destroy coinstats item" do
# Schedules for deletion, doesn't actually delete immediately
assert_no_difference("CoinstatsItem.count") do
delete coinstats_item_url(@coinstats_item)
end
assert_redirected_to settings_providers_path
@coinstats_item.reload
assert @coinstats_item.scheduled_for_deletion?
end
test "should sync coinstats item" do
post sync_coinstats_item_url(@coinstats_item)
assert_redirected_to accounts_path
end
test "sync responds to json format" do
post sync_coinstats_item_url(@coinstats_item, format: :json)
assert_response :ok
end
test "should update coinstats item with valid api key" do
Provider::Coinstats.any_instance.expects(:get_blockchains).returns(success_response([])).once
patch coinstats_item_url(@coinstats_item), params: {
coinstats_item: {
name: "Updated Name",
api_key: "new_valid_api_key"
}
}
@coinstats_item.reload
assert_equal "Updated Name", @coinstats_item.name
end
test "should not update coinstats item with invalid api key" do
Provider::Coinstats.any_instance.expects(:get_blockchains)
.returns(error_response("Invalid API key"))
original_name = @coinstats_item.name
patch coinstats_item_url(@coinstats_item), params: {
coinstats_item: {
name: "Updated Name",
api_key: "invalid_api_key"
}
}
@coinstats_item.reload
assert_equal original_name, @coinstats_item.name
end
test "link_wallet requires all parameters" do
post link_wallet_coinstats_items_url, params: {
coinstats_item_id: @coinstats_item.id,
address: "0x123"
# missing blockchain
}
assert_response :unprocessable_entity
end
test "link_wallet with valid params creates accounts" do
balance_data = [
{ coinId: "ethereum", name: "Ethereum", symbol: "ETH", amount: 1.5, price: 2000 }
]
bulk_response = [
{ blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", balances: balance_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances)
.with("ethereum:0x123abc")
.returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance)
.with(bulk_response, "0x123abc", "ethereum")
.returns(balance_data)
assert_difference("Account.count", 1) do
assert_difference("CoinstatsAccount.count", 1) do
post link_wallet_coinstats_items_url, params: {
coinstats_item_id: @coinstats_item.id,
address: "0x123abc",
blockchain: "ethereum"
}
end
end
assert_redirected_to accounts_path
end
test "link_wallet handles provider errors" do
Provider::Coinstats.any_instance.expects(:get_wallet_balances)
.raises(Provider::Coinstats::Error.new("Invalid API key"))
post link_wallet_coinstats_items_url, params: {
coinstats_item_id: @coinstats_item.id,
address: "0x123abc",
blockchain: "ethereum"
}
assert_response :unprocessable_entity
end
test "link_wallet handles no tokens found" do
Provider::Coinstats.any_instance.expects(:get_wallet_balances)
.returns(success_response([]))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance)
.returns([])
post link_wallet_coinstats_items_url, params: {
coinstats_item_id: @coinstats_item.id,
address: "0x123abc",
blockchain: "ethereum"
}
assert_response :unprocessable_entity
assert_match(/No tokens found/, response.body)
end
end

View File

@@ -191,4 +191,30 @@ class OidcAccountsControllerTest < ActionController::TestCase
assert_redirected_to new_session_path
assert_equal "No pending OIDC authentication found", flash[:alert]
end
# Security: JIT users should NOT have password_digest set
test "JIT user is created without password_digest to prevent chained auth attacks" do
session[:pending_oidc_auth] = new_user_auth
post :create_user
new_user = User.find_by(email: new_user_auth["email"])
assert_not_nil new_user, "User should be created"
assert_nil new_user.password_digest, "JIT user should have nil password_digest"
assert new_user.sso_only?, "JIT user should be SSO-only"
end
test "JIT user cannot authenticate with local password" do
session[:pending_oidc_auth] = new_user_auth
post :create_user
new_user = User.find_by(email: new_user_auth["email"])
# Attempting to authenticate should return nil (no password set)
assert_nil User.authenticate_by(
email: new_user.email,
password: "anypassword"
), "SSO-only user should not authenticate with password"
end
end

View File

@@ -48,4 +48,43 @@ class PasswordResetsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to new_session_path
assert_equal "Password reset via Sure is disabled. Please reset your password through your identity provider.", flash[:alert]
end
# Security: SSO-only users should not receive password reset emails
test "create does not send email for SSO-only user" do
sso_user = users(:sso_only)
assert sso_user.sso_only?, "Test user should be SSO-only"
assert_no_enqueued_emails do
post password_reset_path, params: { email: sso_user.email }
end
# Should still redirect to pending to prevent email enumeration
assert_redirected_to new_password_reset_url(step: "pending")
end
test "create sends email for user with local password" do
assert @user.has_local_password?, "Test user should have local password"
assert_enqueued_emails 1 do
post password_reset_path, params: { email: @user.email }
end
assert_redirected_to new_password_reset_url(step: "pending")
end
# Security: SSO-only users cannot set password via reset
test "update blocks password setting for SSO-only user" do
sso_user = users(:sso_only)
token = sso_user.generate_token_for(:password_reset)
patch password_reset_path(token: token),
params: { user: { password: "NewSecure1!", password_confirmation: "NewSecure1!" } }
assert_redirected_to new_session_path
assert_equal "Your account uses SSO for authentication. Please contact your administrator to manage your credentials.", flash[:alert]
# Verify password was not set
sso_user.reload
assert_nil sso_user.password_digest, "SSO-only user should still have nil password_digest"
end
end

View File

@@ -179,4 +179,17 @@ class RulesControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to rules_url
end
test "should get confirm_all" do
get confirm_all_rules_url
assert_response :success
end
test "apply_all enqueues job and redirects" do
assert_enqueued_with(job: ApplyAllRulesJob) do
post apply_all_rules_url
end
assert_redirected_to rules_url
end
end

View File

@@ -1,6 +1,7 @@
require "test_helper"
class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
include ActiveJob::TestHelper
fixtures :users, :families
setup do
sign_in users(:family_admin)
@@ -154,22 +155,20 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
test "should update simplefin item with valid token" do
@simplefin_item.update!(status: :requires_update)
# Mock the SimpleFin provider to prevent real API calls
mock_provider = mock()
mock_provider.expects(:claim_access_url).with("valid_token").returns("https://example.com/new_access")
mock_provider.expects(:get_accounts).returns({ accounts: [] }).at_least_once
Provider::Simplefin.expects(:new).returns(mock_provider).at_least_once
token = Base64.strict_encode64("https://example.com/claim")
# Let the real create_simplefin_item! method run - don't mock it
SimplefinConnectionUpdateJob.expects(:perform_later).with(
family_id: @family.id,
old_simplefin_item_id: @simplefin_item.id,
setup_token: token
).once
patch simplefin_item_url(@simplefin_item), params: {
simplefin_item: { setup_token: "valid_token" }
simplefin_item: { setup_token: token }
}
assert_redirected_to accounts_path
assert_equal "SimpleFin connection updated.", flash[:notice]
@simplefin_item.reload
assert @simplefin_item.scheduled_for_deletion?
assert_equal "SimpleFIN connection updated.", flash[:notice]
end
test "should handle update with invalid token" do
@@ -180,13 +179,15 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
}
assert_response :unprocessable_entity
assert_includes response.body, I18n.t("simplefin_items.update.errors.blank_token", default: "Please enter a SimpleFin setup token")
assert_includes response.body, I18n.t("simplefin_items.update.errors.blank_token", default: "Please enter a SimpleFIN setup token")
end
test "should transfer accounts when updating simplefin item token" do
@simplefin_item.update!(status: :requires_update)
# Create old SimpleFin accounts linked to Maybe accounts
token = Base64.strict_encode64("https://example.com/claim")
# Create old SimpleFIN accounts linked to Maybe accounts
old_simplefin_account1 = @simplefin_item.simplefin_accounts.create!(
name: "Test Checking",
account_id: "sf_account_123",
@@ -202,7 +203,7 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
account_type: "depository"
)
# Create Maybe accounts linked to the SimpleFin accounts
# Create Maybe accounts linked to the SimpleFIN accounts
maybe_account1 = Account.create!(
family: @family,
name: "Checking Account",
@@ -222,13 +223,13 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
simplefin_account_id: old_simplefin_account2.id
)
# Update old SimpleFin accounts to reference the Maybe accounts
# Update old SimpleFIN accounts to reference the Maybe accounts
old_simplefin_account1.update!(account: maybe_account1)
old_simplefin_account2.update!(account: maybe_account2)
# Mock only the external API calls, let business logic run
mock_provider = mock()
mock_provider.expects(:claim_access_url).with("valid_token").returns("https://example.com/new_access")
mock_provider.expects(:claim_access_url).with(token).returns("https://example.com/new_access")
mock_provider.expects(:get_accounts).returns({
accounts: [
{
@@ -251,41 +252,40 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
}).at_least_once
Provider::Simplefin.expects(:new).returns(mock_provider).at_least_once
# Perform the update
patch simplefin_item_url(@simplefin_item), params: {
simplefin_item: { setup_token: "valid_token" }
}
# Perform the update (async job), but execute enqueued jobs inline so we can
# assert the link transfers.
perform_enqueued_jobs(only: SimplefinConnectionUpdateJob) do
patch simplefin_item_url(@simplefin_item), params: {
simplefin_item: { setup_token: token }
}
end
assert_redirected_to accounts_path
assert_equal "SimpleFin connection updated.", flash[:notice]
assert_equal "SimpleFIN connection updated.", flash[:notice]
# Verify accounts were transferred to new SimpleFin accounts
# Verify accounts were transferred to new SimpleFIN accounts
assert Account.exists?(maybe_account1.id), "maybe_account1 should still exist"
assert Account.exists?(maybe_account2.id), "maybe_account2 should still exist"
maybe_account1.reload
maybe_account2.reload
# Find the new SimpleFin item that was created
# Find the new SimpleFIN item that was created
new_simplefin_item = @family.simplefin_items.where.not(id: @simplefin_item.id).first
assert_not_nil new_simplefin_item, "New SimpleFin item should have been created"
assert_not_nil new_simplefin_item, "New SimpleFIN item should have been created"
new_sf_account1 = new_simplefin_item.simplefin_accounts.find_by(account_id: "sf_account_123")
new_sf_account2 = new_simplefin_item.simplefin_accounts.find_by(account_id: "sf_account_456")
assert_not_nil new_sf_account1, "New SimpleFin account with ID sf_account_123 should exist"
assert_not_nil new_sf_account2, "New SimpleFin account with ID sf_account_456 should exist"
assert_not_nil new_sf_account1, "New SimpleFIN account with ID sf_account_123 should exist"
assert_not_nil new_sf_account2, "New SimpleFIN account with ID sf_account_456 should exist"
assert_equal new_sf_account1.id, maybe_account1.simplefin_account_id
assert_equal new_sf_account2.id, maybe_account2.simplefin_account_id
# Verify old SimpleFin accounts no longer reference Maybe accounts
old_simplefin_account1.reload
old_simplefin_account2.reload
assert_nil old_simplefin_account1.current_account
assert_nil old_simplefin_account2.current_account
# The old item will be deleted asynchronously; until then, legacy links should be moved.
# Verify old SimpleFin item is scheduled for deletion
# Verify old SimpleFIN item is scheduled for deletion
@simplefin_item.reload
assert @simplefin_item.scheduled_for_deletion?
end
@@ -293,7 +293,9 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
test "should handle partial account matching during token update" do
@simplefin_item.update!(status: :requires_update)
# Create old SimpleFin account
token = Base64.strict_encode64("https://example.com/claim")
# Create old SimpleFIN account
old_simplefin_account = @simplefin_item.simplefin_accounts.create!(
name: "Test Checking",
account_id: "sf_account_123",
@@ -302,7 +304,7 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
account_type: "depository"
)
# Create Maybe account linked to the SimpleFin account
# Create Maybe account linked to the SimpleFIN account
maybe_account = Account.create!(
family: @family,
name: "Checking Account",
@@ -316,21 +318,21 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
# Mock only the external API calls, let business logic run
mock_provider = mock()
mock_provider.expects(:claim_access_url).with("valid_token").returns("https://example.com/new_access")
mock_provider.expects(:claim_access_url).with(token).returns("https://example.com/new_access")
# Return empty accounts list to simulate account was removed from bank
mock_provider.expects(:get_accounts).returns({ accounts: [] }).at_least_once
Provider::Simplefin.expects(:new).returns(mock_provider).at_least_once
# Perform update
patch simplefin_item_url(@simplefin_item), params: {
simplefin_item: { setup_token: "valid_token" }
}
perform_enqueued_jobs(only: SimplefinConnectionUpdateJob) do
patch simplefin_item_url(@simplefin_item), params: {
simplefin_item: { setup_token: token }
}
end
assert_response :redirect
uri2 = URI(response.redirect_url)
assert_equal "/accounts", uri2.path
assert_redirected_to accounts_path
# Verify Maybe account still linked to old SimpleFin account (no transfer occurred)
# Verify Maybe account still linked to old SimpleFIN account (no transfer occurred)
maybe_account.reload
old_simplefin_account.reload
assert_equal old_simplefin_account.id, maybe_account.simplefin_account_id
@@ -450,30 +452,27 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
test "update redirects to accounts after setup without forcing a modal" do
@simplefin_item.update!(status: :requires_update)
# Mock provider to return one account so updated_item creates SFAs
mock_provider = mock()
mock_provider.expects(:claim_access_url).with("valid_token").returns("https://example.com/new_access")
mock_provider.expects(:get_accounts).returns({
accounts: [
{ id: "sf_auto_open_1", name: "Auto Open Checking", type: "depository", currency: "USD", balance: 100, transactions: [] }
]
}).at_least_once
Provider::Simplefin.expects(:new).returns(mock_provider).at_least_once
token = Base64.strict_encode64("https://example.com/claim")
patch simplefin_item_url(@simplefin_item), params: { simplefin_item: { setup_token: "valid_token" } }
SimplefinConnectionUpdateJob.expects(:perform_later).with(
family_id: @family.id,
old_simplefin_item_id: @simplefin_item.id,
setup_token: token
).once
assert_response :redirect
uri = URI(response.redirect_url)
assert_equal "/accounts", uri.path
patch simplefin_item_url(@simplefin_item), params: { simplefin_item: { setup_token: token } }
assert_redirected_to accounts_path
end
test "create does not auto-open when no candidates or unlinked" do
# Mock provider interactions for item creation (no immediate account import on create)
mock_provider = mock()
mock_provider.expects(:claim_access_url).with("valid_token").returns("https://example.com/new_access")
token = Base64.strict_encode64("https://example.com/claim")
mock_provider.expects(:claim_access_url).with(token).returns("https://example.com/new_access")
Provider::Simplefin.expects(:new).returns(mock_provider).at_least_once
post simplefin_items_url, params: { simplefin_item: { setup_token: "valid_token" } }
post simplefin_items_url, params: { simplefin_item: { setup_token: token } }
assert_response :redirect
uri = URI(response.redirect_url)
@@ -485,12 +484,15 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
test "update does not auto-open when no SFAs present" do
@simplefin_item.update!(status: :requires_update)
mock_provider = mock()
mock_provider.expects(:claim_access_url).with("valid_token").returns("https://example.com/new_access")
mock_provider.expects(:get_accounts).returns({ accounts: [] }).at_least_once
Provider::Simplefin.expects(:new).returns(mock_provider).at_least_once
token = Base64.strict_encode64("https://example.com/claim")
patch simplefin_item_url(@simplefin_item), params: { simplefin_item: { setup_token: "valid_token" } }
SimplefinConnectionUpdateJob.expects(:perform_later).with(
family_id: @family.id,
old_simplefin_item_id: @simplefin_item.id,
setup_token: token
).once
patch simplefin_item_url(@simplefin_item), params: { simplefin_item: { setup_token: token } }
assert_response :redirect
uri = URI(response.redirect_url)
@@ -498,4 +500,201 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
q = Rack::Utils.parse_nested_query(uri.query)
assert !q.key?("open_relink_for"), "did not expect auto-open when update produced no SFAs/candidates"
end
# Stale account detection and handling tests
test "setup_accounts detects stale accounts not in upstream API" do
# Create a linked SimpleFIN account
linked_sfa = @simplefin_item.simplefin_accounts.create!(
name: "Old Bitcoin",
account_id: "stale_btc_123",
currency: "USD",
current_balance: 0,
account_type: "crypto"
)
linked_account = Account.create!(
family: @family,
name: "Old Bitcoin",
balance: 0,
currency: "USD",
accountable: Crypto.create!
)
linked_sfa.update!(account: linked_account)
linked_account.update!(simplefin_account_id: linked_sfa.id)
# Set raw_payload to simulate upstream API response WITHOUT the stale account
@simplefin_item.update!(raw_payload: {
accounts: [
{ id: "active_cash_456", name: "Cash", balance: 1000, currency: "USD" }
]
})
get setup_accounts_simplefin_item_url(@simplefin_item)
assert_response :success
# Should detect the stale account
assert_includes response.body, "Accounts No Longer in SimpleFIN"
assert_includes response.body, "Old Bitcoin"
end
test "complete_account_setup deletes stale account when delete action selected" do
# Create a linked SimpleFIN account that will be stale
stale_sfa = @simplefin_item.simplefin_accounts.create!(
name: "Stale Account",
account_id: "stale_123",
currency: "USD",
current_balance: 0,
account_type: "depository"
)
stale_account = Account.create!(
family: @family,
name: "Stale Account",
balance: 0,
currency: "USD",
accountable: Depository.create!(subtype: "checking")
)
stale_sfa.update!(account: stale_account)
stale_account.update!(simplefin_account_id: stale_sfa.id)
# Add a transaction to the account
Entry.create!(
account: stale_account,
name: "Test Transaction",
amount: 100,
currency: "USD",
date: Date.today,
entryable: Transaction.create!
)
# Set raw_payload without the stale account
@simplefin_item.update!(raw_payload: { accounts: [] })
assert_difference [ "Account.count", "SimplefinAccount.count", "Entry.count" ], -1 do
post complete_account_setup_simplefin_item_url(@simplefin_item), params: {
stale_account_actions: {
stale_sfa.id => { action: "delete" }
}
}
end
assert_redirected_to accounts_path
end
test "complete_account_setup moves transactions when move action selected" do
# Create source (stale) account
stale_sfa = @simplefin_item.simplefin_accounts.create!(
name: "Bitcoin",
account_id: "stale_btc",
currency: "USD",
current_balance: 0,
account_type: "crypto"
)
stale_account = Account.create!(
family: @family,
name: "Bitcoin",
balance: 0,
currency: "USD",
accountable: Crypto.create!
)
stale_sfa.update!(account: stale_account)
stale_account.update!(simplefin_account_id: stale_sfa.id)
# Create target account (active)
target_sfa = @simplefin_item.simplefin_accounts.create!(
name: "Cash",
account_id: "active_cash",
currency: "USD",
current_balance: 1000,
account_type: "depository"
)
target_account = Account.create!(
family: @family,
name: "Cash",
balance: 1000,
currency: "USD",
accountable: Depository.create!(subtype: "checking")
)
target_sfa.update!(account: target_account)
target_account.update!(simplefin_account_id: target_sfa.id)
target_sfa.ensure_account_provider!
# Add transactions to stale account
entry1 = Entry.create!(
account: stale_account,
name: "P2P Transfer",
amount: 300,
currency: "USD",
date: Date.today,
entryable: Transaction.create!
)
entry2 = Entry.create!(
account: stale_account,
name: "Another Transfer",
amount: 200,
currency: "USD",
date: Date.today - 1,
entryable: Transaction.create!
)
# Set raw_payload with only the target account (stale account missing)
@simplefin_item.update!(raw_payload: {
accounts: [
{ id: "active_cash", name: "Cash", balance: 1000, currency: "USD" }
]
})
# Stale account should be deleted, target account should gain entries
assert_difference "Account.count", -1 do
assert_difference "SimplefinAccount.count", -1 do
post complete_account_setup_simplefin_item_url(@simplefin_item), params: {
stale_account_actions: {
stale_sfa.id => { action: "move", target_account_id: target_account.id }
}
}
end
end
assert_redirected_to accounts_path
# Verify transactions were moved to target account
entry1.reload
entry2.reload
assert_equal target_account.id, entry1.account_id
assert_equal target_account.id, entry2.account_id
end
test "complete_account_setup skips stale account when skip action selected" do
# Create a linked SimpleFIN account that will be stale
stale_sfa = @simplefin_item.simplefin_accounts.create!(
name: "Stale Account",
account_id: "stale_skip",
currency: "USD",
current_balance: 0,
account_type: "depository"
)
stale_account = Account.create!(
family: @family,
name: "Stale Account",
balance: 0,
currency: "USD",
accountable: Depository.create!(subtype: "checking")
)
stale_sfa.update!(account: stale_account)
stale_account.update!(simplefin_account_id: stale_sfa.id)
@simplefin_item.update!(raw_payload: { accounts: [] })
assert_no_difference [ "Account.count", "SimplefinAccount.count" ] do
post complete_account_setup_simplefin_item_url(@simplefin_item), params: {
stale_account_actions: {
stale_sfa.id => { action: "skip" }
}
}
end
assert_redirected_to accounts_path
# Account and SimplefinAccount should still exist
assert Account.exists?(stale_account.id)
assert SimplefinAccount.exists?(stale_sfa.id)
end
end

6
test/fixtures/lunchflow_accounts.yml vendored Normal file
View File

@@ -0,0 +1,6 @@
investment_account:
lunchflow_item: one
account_id: "lf_acc_investment_1"
name: "Test Investment Account"
currency: USD
holdings_supported: true

5
test/fixtures/lunchflow_items.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
one:
family: dylan_family
name: "Test Lunchflow Connection"
api_key: "test_api_key_123"
status: good

View File

@@ -19,3 +19,14 @@ jakob_google:
first_name: Jakob
last_name: Dylan
last_authenticated_at: <%= 2.days.ago %>
sso_only_identity:
user: sso_only
provider: openid_connect
uid: sso-only-uid-12345
info:
email: sso-user@example.com
name: SSO User
first_name: SSO
last_name: User
last_authenticated_at: <%= 1.day.ago %>

View File

@@ -3,9 +3,11 @@ one:
date: <%= Date.current %>
price: 215
currency: USD
provisional: false
two:
security: aapl
date: <%= 1.day.ago.to_date %>
price: 214
currency: USD
provisional: false

2
test/fixtures/simplefin_accounts.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
# Empty fixture to ensure the simplefin_accounts table is truncated during tests.
# Tests create SimplefinAccount records explicitly in setup.

2
test/fixtures/simplefin_items.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
# Empty fixture to ensure the simplefin_items table is truncated during tests.
# Tests create SimplefinItem records explicitly in setup.

View File

@@ -43,6 +43,17 @@ new_email:
last_name: User
email: user@example.com
unconfirmed_email: new@example.com
password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla
password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla
onboarded_at: <%= Time.current %>
ai_enabled: true
# SSO-only user: created via JIT provisioning, no local password
sso_only:
family: empty
first_name: SSO
last_name: User
email: sso-user@example.com
password_digest: ~
role: admin
onboarded_at: <%= 1.day.ago %>
ai_enabled: true

View File

@@ -0,0 +1,41 @@
require "test_helper"
class ApplyAllRulesJobTest < ActiveJob::TestCase
include EntriesTestHelper
setup do
@family = families(:empty)
@account = @family.accounts.create!(name: "Test Account", balance: 1000, currency: "USD", accountable: Depository.new)
@groceries_category = @family.categories.create!(name: "Groceries")
end
test "applies all rules for a family" do
# Create a rule
rule = Rule.create!(
family: @family,
resource_type: "transaction",
effective_date: 1.day.ago.to_date,
conditions: [ Rule::Condition.new(condition_type: "transaction_name", operator: "like", value: "Whole Foods") ],
actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @groceries_category.id) ]
)
# Mock RuleJob to verify it gets called for each rule
RuleJob.expects(:perform_now).with(rule, ignore_attribute_locks: true, execution_type: "manual").once
ApplyAllRulesJob.perform_now(@family)
end
test "applies all rules with custom execution type" do
rule = Rule.create!(
family: @family,
resource_type: "transaction",
effective_date: 1.day.ago.to_date,
conditions: [ Rule::Condition.new(condition_type: "transaction_name", operator: "like", value: "Test") ],
actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @groceries_category.id) ]
)
RuleJob.expects(:perform_now).with(rule, ignore_attribute_locks: true, execution_type: "scheduled").once
ApplyAllRulesJob.perform_now(@family, execution_type: "scheduled")
end
end

View File

@@ -0,0 +1,33 @@
require "test_helper"
class SyncHourlyJobTest < ActiveJob::TestCase
test "syncs all active items for each hourly syncable class" do
mock_item = mock("coinstats_item")
mock_item.expects(:sync_later).once
mock_relation = mock("active_relation")
mock_relation.stubs(:find_each).yields(mock_item)
CoinstatsItem.expects(:active).returns(mock_relation)
SyncHourlyJob.perform_now
end
test "continues syncing other items when one fails" do
failing_item = mock("failing_item")
failing_item.expects(:sync_later).raises(StandardError.new("Test error"))
failing_item.stubs(:id).returns(1)
success_item = mock("success_item")
success_item.expects(:sync_later).once
mock_relation = mock("active_relation")
mock_relation.stubs(:find_each).multiple_yields([ failing_item ], [ success_item ])
CoinstatsItem.expects(:active).returns(mock_relation)
assert_nothing_raised do
SyncHourlyJob.perform_now
end
end
end

View File

@@ -4,7 +4,8 @@ require "ostruct"
class Account::MarketDataImporterTest < ActiveSupport::TestCase
include ProviderTestHelper
PROVIDER_BUFFER = 5.days
SECURITY_PRICE_BUFFER = Security::Price::Importer::PROVISIONAL_LOOKBACK_DAYS.days
EXCHANGE_RATE_BUFFER = 5.days
setup do
# Ensure a clean slate for deterministic assertions
@@ -37,7 +38,7 @@ class Account::MarketDataImporterTest < ActiveSupport::TestCase
ExchangeRate.create!(from_currency: "CAD", to_currency: "USD", date: existing_date, rate: 2.0)
ExchangeRate.create!(from_currency: "USD", to_currency: "CAD", date: existing_date, rate: 0.5)
expected_start_date = (existing_date + 1.day) - PROVIDER_BUFFER
expected_start_date = (existing_date + 1.day) - EXCHANGE_RATE_BUFFER
end_date = Date.current.in_time_zone("America/New_York").to_date
@provider.expects(:fetch_exchange_rates)
@@ -88,7 +89,7 @@ class Account::MarketDataImporterTest < ActiveSupport::TestCase
entryable: trade
)
expected_start_date = trade_date - PROVIDER_BUFFER
expected_start_date = trade_date - SECURITY_PRICE_BUFFER
end_date = Date.current.in_time_zone("America/New_York").to_date
@provider.expects(:fetch_security_prices)
@@ -138,7 +139,7 @@ class Account::MarketDataImporterTest < ActiveSupport::TestCase
entryable: trade
)
expected_start_date = trade_date - PROVIDER_BUFFER
expected_start_date = trade_date - SECURITY_PRICE_BUFFER
end_date = Date.current.in_time_zone("America/New_York").to_date
# Simulate provider returning an error response
@@ -181,7 +182,7 @@ class Account::MarketDataImporterTest < ActiveSupport::TestCase
ExchangeRate.create!(from_currency: "CAD", to_currency: "USD", date: existing_date, rate: 2.0)
ExchangeRate.create!(from_currency: "USD", to_currency: "CAD", date: existing_date, rate: 0.5)
expected_start_date = (existing_date + 1.day) - PROVIDER_BUFFER
expected_start_date = (existing_date + 1.day) - EXCHANGE_RATE_BUFFER
end_date = Date.current.in_time_zone("America/New_York").to_date
# Simulate provider returning an error response

View File

@@ -129,4 +129,49 @@ class AccountProviderTest < ActiveSupport::TestCase
assert_equal "plaid", plaid_provider.provider_name
assert_equal "simplefin", simplefin_provider.provider_name
end
test "destroying account_provider does not destroy non-coinstats provider accounts" do
provider = AccountProvider.create!(
account: @account,
provider: @plaid_account
)
plaid_account_id = @plaid_account.id
assert PlaidAccount.exists?(plaid_account_id)
provider.destroy!
# Non-CoinStats provider accounts should remain (can enter "needs setup" state)
assert PlaidAccount.exists?(plaid_account_id)
end
test "destroying account_provider destroys coinstats provider account" do
coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats",
api_key: "test_key"
)
coinstats_account = CoinstatsAccount.create!(
coinstats_item: coinstats_item,
name: "Test Wallet",
currency: "USD",
current_balance: 1000
)
provider = AccountProvider.create!(
account: @account,
provider: coinstats_account
)
coinstats_account_id = coinstats_account.id
assert CoinstatsAccount.exists?(coinstats_account_id)
provider.destroy!
# CoinStats provider accounts should be destroyed to avoid orphaned records
assert_not CoinstatsAccount.exists?(coinstats_account_id)
end
end

View File

@@ -14,6 +14,64 @@ class AccountTest < ActiveSupport::TestCase
end
end
test "create_and_sync calls sync_later by default" do
Account.any_instance.expects(:sync_later).once
account = Account.create_and_sync({
family: @family,
name: "Test Account",
balance: 100,
currency: "USD",
accountable_type: "Depository",
accountable_attributes: {}
})
assert account.persisted?
assert_equal "USD", account.currency
assert_equal 100, account.balance
end
test "create_and_sync skips sync_later when skip_initial_sync is true" do
Account.any_instance.expects(:sync_later).never
account = Account.create_and_sync(
{
family: @family,
name: "Linked Account",
balance: 500,
currency: "EUR",
accountable_type: "Depository",
accountable_attributes: {}
},
skip_initial_sync: true
)
assert account.persisted?
assert_equal "EUR", account.currency
assert_equal 500, account.balance
end
test "create_and_sync creates opening anchor with correct currency" do
Account.any_instance.stubs(:sync_later)
account = Account.create_and_sync(
{
family: @family,
name: "Test Account",
balance: 1000,
currency: "GBP",
accountable_type: "Depository",
accountable_attributes: {}
},
skip_initial_sync: true
)
opening_anchor = account.valuations.opening_anchor.first
assert_not_nil opening_anchor
assert_equal "GBP", opening_anchor.entry.currency
assert_equal 1000, opening_anchor.entry.amount
end
test "gets short/long subtype label" do
investment = Investment.new(subtype: "hsa")
account = @family.accounts.create!(

View File

@@ -127,4 +127,197 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
assert_equal expected, builder.balance_series.map { |v| v.value.amount }
end
test "uses balances matching account currency for correct chart data" do
# This test verifies that chart data is built from balances with proper currency.
# Data integrity is maintained by:
# 1. Account.create_and_sync with skip_initial_sync: true for linked accounts
# 2. Migration cleanup_orphaned_currency_balances for existing data
account = accounts(:depository)
account.balances.destroy_all
# Account is in USD, create balances in USD
create_balance(account: account, date: 2.days.ago.to_date, balance: 1000)
create_balance(account: account, date: 1.day.ago.to_date, balance: 1500)
create_balance(account: account, date: Date.current, balance: 2000)
builder = Balance::ChartSeriesBuilder.new(
account_ids: [ account.id ],
currency: "USD",
period: Period.custom(start_date: 2.days.ago.to_date, end_date: Date.current),
interval: "1 day"
)
series = builder.balance_series
assert_equal 3, series.size
assert_equal [ 1000, 1500, 2000 ], series.map { |v| v.value.amount }
end
test "balances are converted to target currency using exchange rates" do
# Create account with EUR currency
family = families(:dylan_family)
account = family.accounts.create!(
name: "EUR Account",
balance: 1000,
currency: "EUR",
accountable: Depository.new
)
account.balances.destroy_all
# Create balances in EUR (matching account currency)
create_balance(account: account, date: 1.day.ago.to_date, balance: 1000)
create_balance(account: account, date: Date.current, balance: 1200)
# Add exchange rate EUR -> USD
ExchangeRate.create!(date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.1)
# Request chart in USD (different from account's EUR)
builder = Balance::ChartSeriesBuilder.new(
account_ids: [ account.id ],
currency: "USD",
period: Period.custom(start_date: 1.day.ago.to_date, end_date: Date.current),
interval: "1 day"
)
series = builder.balance_series
# EUR balances converted to USD at 1.1 rate (LOCF for today)
assert_equal [ 1100, 1320 ], series.map { |v| v.value.amount }
end
test "linked account with orphaned currency balances shows correct values after cleanup" do
# This test reproduces the original bug scenario:
# 1. Linked account created with initial sync before correct currency was known
# 2. Opening anchor and first sync created balances with wrong currency (USD)
# 3. Provider sync updated account to correct currency (EUR) and created new balances
# 4. Both USD and EUR balances existed - charts showed wrong values
#
# The fix:
# 1. skip_initial_sync prevents this going forward
# 2. Migration cleans up orphaned balances for existing linked accounts
# Use the connected (linked) account fixture
linked_account = accounts(:connected)
linked_account.balances.destroy_all
# Simulate the bug: account is now EUR but has old USD balances from initial sync
linked_account.update!(currency: "EUR")
# Create orphaned balances in wrong currency (USD) - from initial sync before currency was known
Balance.create!(
account: linked_account,
date: 3.days.ago.to_date,
balance: 1000,
cash_balance: 1000,
currency: "USD", # Wrong currency!
start_cash_balance: 1000,
start_non_cash_balance: 0,
cash_inflows: 0,
cash_outflows: 0,
non_cash_inflows: 0,
non_cash_outflows: 0,
net_market_flows: 0,
cash_adjustments: 0,
non_cash_adjustments: 0,
flows_factor: 1
)
Balance.create!(
account: linked_account,
date: 2.days.ago.to_date,
balance: 1100,
cash_balance: 1100,
currency: "USD", # Wrong currency!
start_cash_balance: 1100,
start_non_cash_balance: 0,
cash_inflows: 0,
cash_outflows: 0,
non_cash_inflows: 0,
non_cash_outflows: 0,
net_market_flows: 0,
cash_adjustments: 0,
non_cash_adjustments: 0,
flows_factor: 1
)
# Create correct balances in EUR - from provider sync after currency was known
create_balance(account: linked_account, date: 1.day.ago.to_date, balance: 5000)
create_balance(account: linked_account, date: Date.current, balance: 5500)
# Verify we have both currency balances (the bug state)
assert_equal 2, linked_account.balances.where(currency: "USD").count
assert_equal 2, linked_account.balances.where(currency: "EUR").count
# Simulate migration cleanup: delete orphaned balances with wrong currency
linked_account.balances.where.not(currency: linked_account.currency).delete_all
# Verify cleanup removed orphaned balances
assert_equal 0, linked_account.balances.where(currency: "USD").count
assert_equal 2, linked_account.balances.where(currency: "EUR").count
# Now chart should show correct EUR values
builder = Balance::ChartSeriesBuilder.new(
account_ids: [ linked_account.id ],
currency: "EUR",
period: Period.custom(start_date: 2.days.ago.to_date, end_date: Date.current),
interval: "1 day"
)
series = builder.balance_series
# After cleanup: only EUR balances exist, chart shows correct values
# Day 2 ago: 0 (no EUR balance), Day 1 ago: 5000, Today: 5500
assert_equal [ 0, 5000, 5500 ], series.map { |v| v.value.amount }
end
test "chart ignores orphaned currency balances via currency filter" do
# This test verifies the currency filter correctly ignores orphaned balances.
# The filter `b.currency = accounts.currency` ensures only valid balances are used.
#
# Bug scenario: Account currency changed from USD to EUR after initial sync,
# leaving orphaned USD balances. Without the filter, charts would show wrong values.
linked_account = accounts(:connected)
linked_account.balances.destroy_all
# Account is EUR but has orphaned USD balances (bug state)
linked_account.update!(currency: "EUR")
# Create orphaned USD balance (wrong currency)
Balance.create!(
account: linked_account,
date: 1.day.ago.to_date,
balance: 9999,
cash_balance: 9999,
currency: "USD", # Wrong currency - doesn't match account.currency (EUR)
start_cash_balance: 9999,
start_non_cash_balance: 0,
cash_inflows: 0,
cash_outflows: 0,
non_cash_inflows: 0,
non_cash_outflows: 0,
net_market_flows: 0,
cash_adjustments: 0,
non_cash_adjustments: 0,
flows_factor: 1
)
# Chart correctly ignores USD balance because account.currency is EUR
builder = Balance::ChartSeriesBuilder.new(
account_ids: [ linked_account.id ],
currency: "EUR",
period: Period.custom(start_date: 1.day.ago.to_date, end_date: Date.current),
interval: "1 day"
)
series = builder.balance_series
# Currency filter ensures orphaned USD balance (9999) is ignored
# Chart shows zeros because no EUR balances exist
assert_equal 2, series.size
assert_equal [ 0, 0 ], series.map { |v| v.value.amount }
# Verify the orphaned balance still exists in DB (migration will clean it up)
assert_equal 1, linked_account.balances.where(currency: "USD").count
assert_equal 0, linked_account.balances.where(currency: "EUR").count
end
end

View File

@@ -0,0 +1,159 @@
require "test_helper"
class CoinstatsAccount::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 Crypto Account",
balance: 1000,
currency: "USD"
)
@coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Test Wallet",
currency: "USD",
current_balance: 2500
)
AccountProvider.create!(account: @account, provider: @coinstats_account)
end
test "skips processing when no linked account" do
# Create an unlinked coinstats account
unlinked_account = @coinstats_item.coinstats_accounts.create!(
name: "Unlinked Wallet",
currency: "USD",
current_balance: 1000
)
processor = CoinstatsAccount::Processor.new(unlinked_account)
# Should not raise, just return early
assert_nothing_raised do
processor.process
end
end
test "updates account balance from coinstats account" do
@coinstats_account.update!(current_balance: 5000.50)
processor = CoinstatsAccount::Processor.new(@coinstats_account)
processor.process
@account.reload
assert_equal BigDecimal("5000.50"), @account.balance
assert_equal BigDecimal("5000.50"), @account.cash_balance
end
test "updates account currency from coinstats account" do
@coinstats_account.update!(currency: "EUR")
processor = CoinstatsAccount::Processor.new(@coinstats_account)
processor.process
@account.reload
assert_equal "EUR", @account.currency
end
test "handles zero balance" do
@coinstats_account.update!(current_balance: 0)
processor = CoinstatsAccount::Processor.new(@coinstats_account)
processor.process
@account.reload
assert_equal BigDecimal("0"), @account.balance
end
test "handles nil balance as zero" do
@coinstats_account.update!(current_balance: nil)
processor = CoinstatsAccount::Processor.new(@coinstats_account)
processor.process
@account.reload
assert_equal BigDecimal("0"), @account.balance
end
test "processes 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: "0xabc123", explorerUrl: "https://etherscan.io/tx/0xabc123" }
}
])
processor = CoinstatsAccount::Processor.new(@coinstats_account)
# Mock the transaction processor to verify it's called
CoinstatsAccount::Transactions::Processor.any_instance
.expects(:process)
.returns({ success: true, total: 1, imported: 1, failed: 0, errors: [] })
.once
processor.process
end
test "continues processing when transaction processing fails" do
@coinstats_account.update!(raw_transactions_payload: [
{ type: "Received", date: "2025-01-15T10:00:00.000Z" }
])
processor = CoinstatsAccount::Processor.new(@coinstats_account)
# Mock transaction processing to raise an error
CoinstatsAccount::Transactions::Processor.any_instance
.expects(:process)
.raises(StandardError.new("Transaction processing error"))
# Should not raise - error is caught and reported
assert_nothing_raised do
processor.process
end
# Balance should still be updated
@account.reload
assert_equal BigDecimal("2500"), @account.balance
end
test "normalizes currency codes" do
@coinstats_account.update!(currency: "usd")
processor = CoinstatsAccount::Processor.new(@coinstats_account)
processor.process
@account.reload
assert_equal "USD", @account.currency
end
test "falls back to account currency when coinstats currency is nil" do
@account.update!(currency: "GBP")
# Use update_column to bypass validation
@coinstats_account.update_column(:currency, "")
processor = CoinstatsAccount::Processor.new(@coinstats_account)
processor.process
@account.reload
# Empty currency falls through to account's existing currency
assert_equal "GBP", @account.currency
end
test "raises error when account update fails" do
# Make the account invalid by directly modifying a validation constraint
Account.any_instance.stubs(:update!).raises(ActiveRecord::RecordInvalid.new(@account))
processor = CoinstatsAccount::Processor.new(@coinstats_account)
assert_raises(ActiveRecord::RecordInvalid) do
processor.process
end
end
end

View File

@@ -0,0 +1,350 @@
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

View File

@@ -0,0 +1,202 @@
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
end

View File

@@ -0,0 +1,267 @@
require "test_helper"
class CoinstatsEntry::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 Crypto Account",
balance: 1000,
currency: "USD"
)
@coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Test ETH Wallet",
currency: "USD",
current_balance: 5000,
institution_metadata: { "logo" => "https://example.com/eth.png" }
)
AccountProvider.create!(account: @account, provider: @coinstats_account)
end
test "processes received transaction" do
transaction_data = {
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.5, symbol: "ETH", currentValue: 3000 },
hash: { id: "0xabc123", explorerUrl: "https://etherscan.io/tx/0xabc123" }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
assert_difference "Entry.count", 1 do
processor.process
end
entry = @account.entries.last
assert_equal "coinstats_0xabc123", entry.external_id
assert_equal BigDecimal("-3000"), entry.amount # Negative = income
assert_equal "USD", entry.currency
assert_equal Date.new(2025, 1, 15), entry.date
assert_equal "Received ETH", entry.name
end
test "processes sent transaction" do
transaction_data = {
type: "Sent",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: -0.5, symbol: "ETH", currentValue: 1000 },
hash: { id: "0xdef456", explorerUrl: "https://etherscan.io/tx/0xdef456" }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
assert_difference "Entry.count", 1 do
processor.process
end
entry = @account.entries.last
assert_equal BigDecimal("1000"), entry.amount # Positive = expense
assert_equal "Sent ETH", entry.name
end
test "stores extra metadata" do
transaction_data = {
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xmeta123", explorerUrl: "https://etherscan.io/tx/0xmeta123" },
profitLoss: { profit: 100.50, profitPercent: 5.25 },
fee: { count: 0.001, coin: { symbol: "ETH" }, totalWorth: 2.0 }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
processor.process
entry = @account.entries.last
extra = entry.transaction.extra["coinstats"]
assert_equal "0xmeta123", extra["transaction_hash"]
assert_equal "https://etherscan.io/tx/0xmeta123", extra["explorer_url"]
assert_equal "Received", extra["transaction_type"]
assert_equal "ETH", extra["symbol"]
assert_equal 1.0, extra["count"]
assert_equal 100.50, extra["profit"]
assert_equal 5.25, extra["profit_percent"]
assert_equal 0.001, extra["fee_amount"]
assert_equal "ETH", extra["fee_symbol"]
assert_equal 2.0, extra["fee_usd"]
end
test "handles UTXO transaction ID format" do
transaction_data = {
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 0.1, symbol: "BTC", currentValue: 4000 },
transactions: [
{ items: [ { id: "utxo_tx_id_123" } ] }
]
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
processor.process
entry = @account.entries.last
assert_equal "coinstats_utxo_tx_id_123", entry.external_id
end
test "generates fallback ID when no hash available" do
transaction_data = {
type: "Swap",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 100, symbol: "USDC", currentValue: 100 }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
processor.process
entry = @account.entries.last
# Fallback IDs use a hash digest format: "coinstats_fallback_<16-char-hex>"
assert_match(/^coinstats_fallback_[a-f0-9]{16}$/, entry.external_id)
end
test "raises error when transaction missing identifier" do
transaction_data = {
type: nil,
date: nil,
coinData: { count: nil }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
assert_raises(ArgumentError) do
processor.process
end
end
test "skips processing when no linked account" do
unlinked_account = @coinstats_item.coinstats_accounts.create!(
name: "Unlinked",
currency: "USD"
)
transaction_data = {
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xskip123" }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: unlinked_account)
assert_no_difference "Entry.count" do
result = processor.process
assert_nil result
end
end
test "creates notes with transaction details" do
transaction_data = {
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.5, symbol: "ETH", currentValue: 3000 },
hash: { id: "0xnotes123", explorerUrl: "https://etherscan.io/tx/0xnotes123" },
profitLoss: { profit: 150.00, profitPercent: 10.0 },
fee: { count: 0.002, coin: { symbol: "ETH" }, totalWorth: 4.0 }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
processor.process
entry = @account.entries.last
assert_includes entry.notes, "1.5 ETH"
assert_includes entry.notes, "Fee: 0.002 ETH"
assert_includes entry.notes, "P/L: $150.0 (10.0%)"
assert_includes entry.notes, "Explorer: https://etherscan.io/tx/0xnotes123"
end
test "handles integer timestamp" do
timestamp = Time.new(2025, 1, 15, 10, 0, 0).to_i
transaction_data = {
type: "Received",
date: timestamp,
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xtimestamp123" }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
processor.process
entry = @account.entries.last
assert_equal Date.new(2025, 1, 15), entry.date
end
test "raises error for missing date" do
transaction_data = {
type: "Received",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xnodate123" }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
assert_raises(ArgumentError) do
processor.process
end
end
test "builds name with symbol preferring it over coin name" do
transaction_data = {
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "WETH" },
hash: { id: "0xname123" },
profitLoss: { currentValue: 2000 },
transactions: [
{ items: [ { coin: { name: "Wrapped Ether" } } ] }
]
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
processor.process
entry = @account.entries.last
assert_equal "Received WETH", entry.name
end
test "handles swap out as outgoing transaction" do
transaction_data = {
type: "swap_out",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xswap123" }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
processor.process
entry = @account.entries.last
assert_equal BigDecimal("2000"), entry.amount # Positive = expense/outflow
end
test "is idempotent - does not duplicate transactions" do
transaction_data = {
type: "Received",
date: "2025-01-15T10:00:00.000Z",
coinData: { count: 1.0, symbol: "ETH", currentValue: 2000 },
hash: { id: "0xidempotent123" }
}
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: @coinstats_account)
assert_difference "Entry.count", 1 do
processor.process
end
# Processing again should not create duplicate
assert_no_difference "Entry.count" do
processor.process
end
end
end

View File

@@ -0,0 +1,480 @@
require "test_helper"
class CoinstatsItem::ImporterTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats Connection",
api_key: "test_api_key_123"
)
@mock_provider = mock("Provider::Coinstats")
end
# Helper to wrap data in Provider::Response
def success_response(data)
Provider::Response.new(success?: true, data: data, error: nil)
end
def error_response(message)
Provider::Response.new(success?: false, data: nil, error: Provider::Error.new(message))
end
test "returns early when no linked accounts" do
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
result = importer.import
assert result[:success]
assert_equal 0, result[:accounts_updated]
assert_equal 0, result[:transactions_imported]
end
test "updates linked accounts with balance data" do
# Create a linked coinstats account
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Ethereum",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Ethereum Wallet",
currency: "USD",
raw_payload: { address: "0x123abc", blockchain: "ethereum" }
)
AccountProvider.create!(account: account, provider: coinstats_account)
# Mock balance response
balance_data = [
{ coinId: "ethereum", name: "Ethereum", symbol: "ETH", amount: 1.5, price: 2000, imgUrl: "https://example.com/eth.png" }
]
bulk_response = [
{ blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", balances: balance_data }
]
@mock_provider.expects(:get_wallet_balances)
.with("ethereum:0x123abc")
.returns(success_response(bulk_response))
@mock_provider.expects(:extract_wallet_balance)
.with(bulk_response, "0x123abc", "ethereum")
.returns(balance_data)
bulk_transactions_response = [
{ blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", transactions: [] }
]
@mock_provider.expects(:get_wallet_transactions)
.with("ethereum:0x123abc")
.returns(success_response(bulk_transactions_response))
@mock_provider.expects(:extract_wallet_transactions)
.with(bulk_transactions_response, "0x123abc", "ethereum")
.returns([])
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
result = importer.import
assert result[:success]
assert_equal 1, result[:accounts_updated]
assert_equal 0, result[:accounts_failed]
end
test "skips account when missing address or blockchain" do
# Create a linked account with missing wallet info
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Crypto",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Missing Info Wallet",
currency: "USD",
raw_payload: {} # Missing address and blockchain
)
AccountProvider.create!(account: account, provider: coinstats_account)
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
result = importer.import
# The import succeeds but no accounts are updated (missing info returns success: false)
assert result[:success] # No exceptions = success
assert_equal 0, result[:accounts_updated]
assert_equal 0, result[:accounts_failed] # Doesn't count as "failed" - only exceptions do
end
test "imports transactions and merges with existing" do
# Create a linked coinstats account with existing transactions
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Ethereum",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Ethereum Wallet",
currency: "USD",
account_id: "ethereum",
raw_payload: { address: "0x123abc", blockchain: "ethereum" },
raw_transactions_payload: [
{ hash: { id: "0xexisting1" }, type: "Received", date: "2025-01-01T10:00:00.000Z", transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ] }
]
)
AccountProvider.create!(account: account, provider: coinstats_account)
balance_data = [
{ coinId: "ethereum", name: "Ethereum", symbol: "ETH", amount: 2.0, price: 2500 }
]
bulk_response = [
{ blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", balances: balance_data }
]
@mock_provider.expects(:get_wallet_balances)
.with("ethereum:0x123abc")
.returns(success_response(bulk_response))
@mock_provider.expects(:extract_wallet_balance)
.with(bulk_response, "0x123abc", "ethereum")
.returns(balance_data)
new_transactions = [
{ hash: { id: "0xexisting1" }, type: "Received", date: "2025-01-01T10:00:00.000Z", transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ] }, # duplicate
{ hash: { id: "0xnew1" }, type: "Sent", date: "2025-01-02T11:00:00.000Z", transactions: [ { items: [ { coin: { id: "ethereum" } } ] } ] } # new
]
bulk_transactions_response = [
{ blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", transactions: new_transactions }
]
@mock_provider.expects(:get_wallet_transactions)
.with("ethereum:0x123abc")
.returns(success_response(bulk_transactions_response))
@mock_provider.expects(:extract_wallet_transactions)
.with(bulk_transactions_response, "0x123abc", "ethereum")
.returns(new_transactions)
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
result = importer.import
assert result[:success]
assert_equal 1, result[:accounts_updated]
# Should have 2 transactions (1 existing + 1 new, no duplicate)
coinstats_account.reload
assert_equal 2, coinstats_account.raw_transactions_payload.count
end
test "handles rate limit error during transactions fetch gracefully" do
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Ethereum",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Ethereum Wallet",
currency: "USD",
raw_payload: { address: "0x123abc", blockchain: "ethereum" }
)
AccountProvider.create!(account: account, provider: coinstats_account)
balance_data = [
{ coinId: "ethereum", name: "Ethereum", symbol: "ETH", amount: 1.0, price: 2000 }
]
bulk_response = [
{ blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", balances: balance_data }
]
@mock_provider.expects(:get_wallet_balances)
.with("ethereum:0x123abc")
.returns(success_response(bulk_response))
@mock_provider.expects(:extract_wallet_balance)
.with(bulk_response, "0x123abc", "ethereum")
.returns(balance_data)
# Bulk transaction fetch fails with error - returns error response from fetch_transactions_for_accounts
@mock_provider.expects(:get_wallet_transactions)
.with("ethereum:0x123abc")
.raises(Provider::Coinstats::Error.new("Rate limited"))
# When bulk fetch fails, extract_wallet_transactions is not called (bulk_transactions_data is nil)
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
result = importer.import
# Should still succeed since balance was updated
assert result[:success]
assert_equal 1, result[:accounts_updated]
assert_equal 0, result[:transactions_imported]
end
test "calculates balance from matching token only, not all tokens" do
# Create two accounts for different tokens in the same wallet
crypto1 = Crypto.create!
account1 = @family.accounts.create!(
accountable: crypto1,
name: "Ethereum (0xmu...ulti)",
balance: 0,
currency: "USD"
)
coinstats_account1 = @coinstats_item.coinstats_accounts.create!(
name: "Ethereum (0xmu...ulti)",
currency: "USD",
account_id: "ethereum",
raw_payload: { address: "0xmulti", blockchain: "ethereum" }
)
AccountProvider.create!(account: account1, provider: coinstats_account1)
crypto2 = Crypto.create!
account2 = @family.accounts.create!(
accountable: crypto2,
name: "Dai Stablecoin (0xmu...ulti)",
balance: 0,
currency: "USD"
)
coinstats_account2 = @coinstats_item.coinstats_accounts.create!(
name: "Dai Stablecoin (0xmu...ulti)",
currency: "USD",
account_id: "dai",
raw_payload: { address: "0xmulti", blockchain: "ethereum" }
)
AccountProvider.create!(account: account2, provider: coinstats_account2)
# Multiple tokens with different values
balance_data = [
{ coinId: "ethereum", name: "Ethereum", symbol: "ETH", amount: 2.0, price: 2000 }, # $4000
{ coinId: "dai", name: "Dai Stablecoin", symbol: "DAI", amount: 1000, price: 1 } # $1000
]
# Both accounts share the same wallet address/blockchain, so only one unique wallet
bulk_response = [
{ blockchain: "ethereum", address: "0xmulti", connectionId: "ethereum", balances: balance_data }
]
@mock_provider.expects(:get_wallet_balances)
.with("ethereum:0xmulti")
.returns(success_response(bulk_response))
@mock_provider.expects(:extract_wallet_balance)
.with(bulk_response, "0xmulti", "ethereum")
.returns(balance_data)
.twice
bulk_transactions_response = [
{ blockchain: "ethereum", address: "0xmulti", connectionId: "ethereum", transactions: [] }
]
@mock_provider.expects(:get_wallet_transactions)
.with("ethereum:0xmulti")
.returns(success_response(bulk_transactions_response))
@mock_provider.expects(:extract_wallet_transactions)
.with(bulk_transactions_response, "0xmulti", "ethereum")
.returns([])
.twice
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
importer.import
coinstats_account1.reload
coinstats_account2.reload
# Each account should have only its matching token's balance, not the total
# ETH: 2.0 * 2000 = $4000
assert_equal 4000.0, coinstats_account1.current_balance.to_f
# DAI: 1000 * 1 = $1000
assert_equal 1000.0, coinstats_account2.current_balance.to_f
end
test "handles api errors for individual accounts without failing entire import" do
crypto1 = Crypto.create!
account1 = @family.accounts.create!(
accountable: crypto1,
name: "Working Wallet",
balance: 1000,
currency: "USD"
)
coinstats_account1 = @coinstats_item.coinstats_accounts.create!(
name: "Working Wallet",
currency: "USD",
raw_payload: { address: "0xworking", blockchain: "ethereum" }
)
AccountProvider.create!(account: account1, provider: coinstats_account1)
crypto2 = Crypto.create!
account2 = @family.accounts.create!(
accountable: crypto2,
name: "Failing Wallet",
balance: 500,
currency: "USD"
)
coinstats_account2 = @coinstats_item.coinstats_accounts.create!(
name: "Failing Wallet",
currency: "USD",
raw_payload: { address: "0xfailing", blockchain: "ethereum" }
)
AccountProvider.create!(account: account2, provider: coinstats_account2)
# With multiple wallets, bulk endpoint is used
# Bulk response includes only the working wallet's data
bulk_response = [
{
blockchain: "ethereum",
address: "0xworking",
connectionId: "ethereum",
balances: [ { coinId: "ethereum", name: "Ethereum", amount: 1.0, price: 2000 } ]
}
# 0xfailing not included - simulates partial failure or missing data
]
@mock_provider.expects(:get_wallet_balances)
.with("ethereum:0xworking,ethereum:0xfailing")
.returns(success_response(bulk_response))
@mock_provider.expects(:extract_wallet_balance)
.with(bulk_response, "0xworking", "ethereum")
.returns([ { coinId: "ethereum", name: "Ethereum", amount: 1.0, price: 2000 } ])
@mock_provider.expects(:extract_wallet_balance)
.with(bulk_response, "0xfailing", "ethereum")
.returns([]) # Empty array for missing wallet
bulk_transactions_response = [
{
blockchain: "ethereum",
address: "0xworking",
connectionId: "ethereum",
transactions: []
}
]
@mock_provider.expects(:get_wallet_transactions)
.with("ethereum:0xworking,ethereum:0xfailing")
.returns(success_response(bulk_transactions_response))
@mock_provider.expects(:extract_wallet_transactions)
.with(bulk_transactions_response, "0xworking", "ethereum")
.returns([])
@mock_provider.expects(:extract_wallet_transactions)
.with(bulk_transactions_response, "0xfailing", "ethereum")
.returns([])
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
result = importer.import
assert result[:success] # Both accounts updated (one with empty balance)
assert_equal 2, result[:accounts_updated]
assert_equal 0, result[:accounts_failed]
end
test "uses bulk endpoint for multiple unique wallets and falls back on error" do
# Create accounts with two different wallet addresses
crypto1 = Crypto.create!
account1 = @family.accounts.create!(
accountable: crypto1,
name: "Ethereum Wallet",
balance: 0,
currency: "USD"
)
coinstats_account1 = @coinstats_item.coinstats_accounts.create!(
name: "Ethereum Wallet",
currency: "USD",
raw_payload: { address: "0xeth123", blockchain: "ethereum" }
)
AccountProvider.create!(account: account1, provider: coinstats_account1)
crypto2 = Crypto.create!
account2 = @family.accounts.create!(
accountable: crypto2,
name: "Bitcoin Wallet",
balance: 0,
currency: "USD"
)
coinstats_account2 = @coinstats_item.coinstats_accounts.create!(
name: "Bitcoin Wallet",
currency: "USD",
raw_payload: { address: "bc1qbtc456", blockchain: "bitcoin" }
)
AccountProvider.create!(account: account2, provider: coinstats_account2)
# Bulk endpoint returns data for both wallets
bulk_response = [
{
blockchain: "ethereum",
address: "0xeth123",
connectionId: "ethereum",
balances: [ { coinId: "ethereum", name: "Ethereum", amount: 2.0, price: 2500 } ]
},
{
blockchain: "bitcoin",
address: "bc1qbtc456",
connectionId: "bitcoin",
balances: [ { coinId: "bitcoin", name: "Bitcoin", amount: 0.1, price: 45000 } ]
}
]
@mock_provider.expects(:get_wallet_balances)
.with("ethereum:0xeth123,bitcoin:bc1qbtc456")
.returns(success_response(bulk_response))
@mock_provider.expects(:extract_wallet_balance)
.with(bulk_response, "0xeth123", "ethereum")
.returns([ { coinId: "ethereum", name: "Ethereum", amount: 2.0, price: 2500 } ])
@mock_provider.expects(:extract_wallet_balance)
.with(bulk_response, "bc1qbtc456", "bitcoin")
.returns([ { coinId: "bitcoin", name: "Bitcoin", amount: 0.1, price: 45000 } ])
bulk_transactions_response = [
{
blockchain: "ethereum",
address: "0xeth123",
connectionId: "ethereum",
transactions: []
},
{
blockchain: "bitcoin",
address: "bc1qbtc456",
connectionId: "bitcoin",
transactions: []
}
]
@mock_provider.expects(:get_wallet_transactions)
.with("ethereum:0xeth123,bitcoin:bc1qbtc456")
.returns(success_response(bulk_transactions_response))
@mock_provider.expects(:extract_wallet_transactions)
.with(bulk_transactions_response, "0xeth123", "ethereum")
.returns([])
@mock_provider.expects(:extract_wallet_transactions)
.with(bulk_transactions_response, "bc1qbtc456", "bitcoin")
.returns([])
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
result = importer.import
assert result[:success]
assert_equal 2, result[:accounts_updated]
# Verify balances were updated
coinstats_account1.reload
coinstats_account2.reload
assert_equal 5000.0, coinstats_account1.current_balance.to_f # 2.0 * 2500
assert_equal 4500.0, coinstats_account2.current_balance.to_f # 0.1 * 45000
end
end

View File

@@ -0,0 +1,177 @@
require "test_helper"
class CoinstatsItem::SyncerTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats Connection",
api_key: "test_api_key_123"
)
@syncer = CoinstatsItem::Syncer.new(@coinstats_item)
end
test "perform_sync imports data from coinstats api" do
mock_sync = mock("sync")
mock_sync.stubs(:respond_to?).with(:status_text).returns(true)
mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true)
mock_sync.stubs(:window_start_date).returns(nil)
mock_sync.stubs(:window_end_date).returns(nil)
mock_sync.expects(:update!).at_least_once
@coinstats_item.expects(:import_latest_coinstats_data).once
@syncer.perform_sync(mock_sync)
end
test "perform_sync updates pending_account_setup when unlinked accounts exist" do
# Create an unlinked coinstats account (no AccountProvider)
@coinstats_item.coinstats_accounts.create!(
name: "Unlinked Wallet",
currency: "USD"
)
mock_sync = mock("sync")
mock_sync.stubs(:respond_to?).with(:status_text).returns(true)
mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true)
mock_sync.stubs(:window_start_date).returns(nil)
mock_sync.stubs(:window_end_date).returns(nil)
mock_sync.expects(:update!).at_least_once
@coinstats_item.expects(:import_latest_coinstats_data).once
@syncer.perform_sync(mock_sync)
assert @coinstats_item.reload.pending_account_setup?
end
test "perform_sync clears pending_account_setup when all accounts linked" do
@coinstats_item.update!(pending_account_setup: true)
# Create a linked coinstats account
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Crypto",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Linked Wallet",
currency: "USD"
)
AccountProvider.create!(account: account, provider: coinstats_account)
mock_sync = mock("sync")
mock_sync.stubs(:respond_to?).with(:status_text).returns(true)
mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true)
mock_sync.stubs(:window_start_date).returns(nil)
mock_sync.stubs(:window_end_date).returns(nil)
mock_sync.expects(:update!).at_least_once
@coinstats_item.expects(:import_latest_coinstats_data).once
@coinstats_item.expects(:process_accounts).once
@coinstats_item.expects(:schedule_account_syncs).once
@syncer.perform_sync(mock_sync)
refute @coinstats_item.reload.pending_account_setup?
end
test "perform_sync processes accounts when linked accounts exist" do
# Create a linked coinstats account
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Crypto",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Linked Wallet",
currency: "USD"
)
AccountProvider.create!(account: account, provider: coinstats_account)
mock_sync = mock("sync")
mock_sync.stubs(:respond_to?).with(:status_text).returns(true)
mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true)
mock_sync.stubs(:window_start_date).returns(nil)
mock_sync.stubs(:window_end_date).returns(nil)
mock_sync.expects(:update!).at_least_once
@coinstats_item.expects(:import_latest_coinstats_data).once
@coinstats_item.expects(:process_accounts).once
@coinstats_item.expects(:schedule_account_syncs).with(
parent_sync: mock_sync,
window_start_date: nil,
window_end_date: nil
).once
@syncer.perform_sync(mock_sync)
end
test "perform_sync skips processing when no linked accounts" do
mock_sync = mock("sync")
mock_sync.stubs(:respond_to?).with(:status_text).returns(true)
mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true)
mock_sync.stubs(:window_start_date).returns(nil)
mock_sync.stubs(:window_end_date).returns(nil)
mock_sync.expects(:update!).at_least_once
@coinstats_item.expects(:import_latest_coinstats_data).once
@coinstats_item.expects(:process_accounts).never
@coinstats_item.expects(:schedule_account_syncs).never
@syncer.perform_sync(mock_sync)
end
test "perform_sync records sync stats" do
# Create one linked and one unlinked account
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Crypto",
balance: 1000,
currency: "USD"
)
linked_account = @coinstats_item.coinstats_accounts.create!(
name: "Linked Wallet",
currency: "USD"
)
AccountProvider.create!(account: account, provider: linked_account)
@coinstats_item.coinstats_accounts.create!(
name: "Unlinked Wallet",
currency: "USD"
)
recorded_stats = nil
mock_sync = mock("sync")
mock_sync.stubs(:respond_to?).with(:status_text).returns(true)
mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true)
mock_sync.stubs(:window_start_date).returns(nil)
mock_sync.stubs(:window_end_date).returns(nil)
mock_sync.expects(:update!).at_least_once.with do |args|
recorded_stats = args[:sync_stats] if args.key?(:sync_stats)
true
end
@coinstats_item.expects(:import_latest_coinstats_data).once
@coinstats_item.expects(:process_accounts).once
@coinstats_item.expects(:schedule_account_syncs).once
@syncer.perform_sync(mock_sync)
assert_equal 2, recorded_stats[:total_accounts]
assert_equal 1, recorded_stats[:linked_accounts]
assert_equal 1, recorded_stats[:unlinked_accounts]
end
test "perform_post_sync is a no-op" do
# Just ensure it doesn't raise
assert_nothing_raised do
@syncer.perform_post_sync
end
end
end

View File

@@ -0,0 +1,280 @@
require "test_helper"
class CoinstatsItem::WalletLinkerTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats Connection",
api_key: "test_api_key_123"
)
end
# Helper to wrap data in Provider::Response
def success_response(data)
Provider::Response.new(success?: true, data: data, error: nil)
end
test "link returns failure when no tokens found" do
Provider::Coinstats.any_instance.expects(:get_wallet_balances)
.with("ethereum:0x123abc")
.returns(success_response([]))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance)
.with([], "0x123abc", "ethereum")
.returns([])
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0x123abc", blockchain: "ethereum")
result = linker.link
refute result.success?
assert_equal 0, result.created_count
assert_includes result.errors, "No tokens found for wallet"
end
test "link creates account from single token" do
token_data = [
{
coinId: "ethereum",
name: "Ethereum",
symbol: "ETH",
amount: 1.5,
price: 2000,
imgUrl: "https://example.com/eth.png"
}
]
bulk_response = [
{ blockchain: "ethereum", address: "0x123abc", connectionId: "ethereum", balances: token_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances)
.with("ethereum:0x123abc")
.returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance)
.with(bulk_response, "0x123abc", "ethereum")
.returns(token_data)
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0x123abc", blockchain: "ethereum")
assert_difference [ "Account.count", "CoinstatsAccount.count", "AccountProvider.count" ], 1 do
result = linker.link
assert result.success?
assert_equal 1, result.created_count
assert_empty result.errors
end
# Verify the account was created correctly
coinstats_account = @coinstats_item.coinstats_accounts.last
# Note: upsert_coinstats_snapshot! overwrites name with raw token name
assert_equal "Ethereum", coinstats_account.name
assert_equal "USD", coinstats_account.currency
assert_equal 3000.0, coinstats_account.current_balance.to_f # 1.5 * 2000
account = coinstats_account.account
# Account name is set before upsert_coinstats_snapshot so it keeps the formatted name
assert_equal "Ethereum (0x12...3abc)", account.name
assert_equal 3000.0, account.balance.to_f
assert_equal "USD", account.currency
assert_equal "Crypto", account.accountable_type
end
test "link creates multiple accounts from multiple tokens" do
token_data = [
{ coinId: "ethereum", name: "Ethereum", symbol: "ETH", amount: 2.0, price: 2000 },
{ coinId: "dai", name: "Dai Stablecoin", symbol: "DAI", amount: 1000, price: 1 }
]
bulk_response = [
{ blockchain: "ethereum", address: "0xmulti", connectionId: "ethereum", balances: token_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances)
.with("ethereum:0xmulti")
.returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance)
.with(bulk_response, "0xmulti", "ethereum")
.returns(token_data)
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0xmulti", blockchain: "ethereum")
assert_difference "Account.count", 2 do
assert_difference "CoinstatsAccount.count", 2 do
result = linker.link
assert result.success?
assert_equal 2, result.created_count
end
end
end
test "link triggers sync after creating accounts" do
token_data = [
{ coinId: "ethereum", name: "Ethereum", amount: 1.0, price: 2000 }
]
bulk_response = [
{ blockchain: "ethereum", address: "0x123", connectionId: "ethereum", balances: token_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns(token_data)
@coinstats_item.expects(:sync_later).once
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0x123", blockchain: "ethereum")
linker.link
end
test "link does not trigger sync when no accounts created" do
Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response([]))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns([])
@coinstats_item.expects(:sync_later).never
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0x123", blockchain: "ethereum")
linker.link
end
test "link stores wallet metadata in raw_payload" do
token_data = [
{
coinId: "ethereum",
name: "Ethereum",
symbol: "ETH",
amount: 1.0,
price: 2000,
imgUrl: "https://example.com/eth.png"
}
]
bulk_response = [
{ blockchain: "ethereum", address: "0xtest123", connectionId: "ethereum", balances: token_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances)
.with("ethereum:0xtest123")
.returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance)
.with(bulk_response, "0xtest123", "ethereum")
.returns(token_data)
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0xtest123", blockchain: "ethereum")
linker.link
coinstats_account = @coinstats_item.coinstats_accounts.last
raw_payload = coinstats_account.raw_payload
assert_equal "0xtest123", raw_payload["address"]
assert_equal "ethereum", raw_payload["blockchain"]
assert_equal "https://example.com/eth.png", raw_payload["institution_logo"]
end
test "link handles account creation errors gracefully" do
token_data = [
{ coinId: "ethereum", name: "Ethereum", amount: 1.0, price: 2000 },
{ coinId: "bad", name: nil, amount: 1.0, price: 100 } # Will fail validation
]
bulk_response = [
{ blockchain: "ethereum", address: "0xtest", connectionId: "ethereum", balances: token_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns(token_data)
# We need to mock the error scenario - name can't be blank
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0xtest", blockchain: "ethereum")
result = linker.link
# Should create the valid account but have errors for the invalid one
assert result.success? # At least one succeeded
assert result.created_count >= 1
end
test "link builds correct account name with address suffix" do
token_data = [
{ coinId: "ethereum", name: "Ethereum", amount: 1.0, price: 2000 }
]
bulk_response = [
{ blockchain: "ethereum", address: "0xABCDEF123456", connectionId: "ethereum", balances: token_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns(token_data)
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0xABCDEF123456", blockchain: "ethereum")
linker.link
# Account name includes the address suffix (created before upsert_coinstats_snapshot)
account = @coinstats_item.accounts.last
assert_equal "Ethereum (0xAB...3456)", account.name
end
test "link handles single token as hash instead of array" do
token_data = {
coinId: "bitcoin",
name: "Bitcoin",
symbol: "BTC",
amount: 0.5,
price: 40000
}
bulk_response = [
{ blockchain: "bitcoin", address: "bc1qtest", connectionId: "bitcoin", balances: [ token_data ] }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns(token_data)
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "bc1qtest", blockchain: "bitcoin")
assert_difference "Account.count", 1 do
result = linker.link
assert result.success?
end
account = @coinstats_item.coinstats_accounts.last
assert_equal 20000.0, account.current_balance.to_f # 0.5 * 40000
end
test "link stores correct account_id from token" do
token_data = [
{ coinId: "unique_token_123", name: "My Token", amount: 100, price: 1 }
]
bulk_response = [
{ blockchain: "ethereum", address: "0xtest", connectionId: "ethereum", balances: token_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns(token_data)
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0xtest", blockchain: "ethereum")
linker.link
coinstats_account = @coinstats_item.coinstats_accounts.last
assert_equal "unique_token_123", coinstats_account.account_id
end
test "link falls back to id field for account_id" do
token_data = [
{ id: "fallback_id_456", name: "Fallback Token", amount: 50, price: 2 }
]
bulk_response = [
{ blockchain: "ethereum", address: "0xtest", connectionId: "ethereum", balances: token_data }
]
Provider::Coinstats.any_instance.expects(:get_wallet_balances).returns(success_response(bulk_response))
Provider::Coinstats.any_instance.expects(:extract_wallet_balance).returns(token_data)
linker = CoinstatsItem::WalletLinker.new(@coinstats_item, address: "0xtest", blockchain: "ethereum")
linker.link
coinstats_account = @coinstats_item.coinstats_accounts.last
assert_equal "fallback_id_456", coinstats_account.account_id
end
end

View File

@@ -0,0 +1,231 @@
require "test_helper"
class CoinstatsItemTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats Connection",
api_key: "test_api_key_123"
)
end
test "belongs to family" do
assert_equal @family, @coinstats_item.family
end
test "has many coinstats_accounts" do
account = @coinstats_item.coinstats_accounts.create!(
name: "Test Wallet",
currency: "USD",
current_balance: 1000.00
)
assert_includes @coinstats_item.coinstats_accounts, account
end
test "has good status by default" do
assert_equal "good", @coinstats_item.status
end
test "can be marked for deletion" do
refute @coinstats_item.scheduled_for_deletion?
@coinstats_item.destroy_later
assert @coinstats_item.scheduled_for_deletion?
end
test "is syncable" do
assert_respond_to @coinstats_item, :sync_later
assert_respond_to @coinstats_item, :syncing?
end
test "requires name to be present" do
coinstats_item = CoinstatsItem.new(family: @family, api_key: "key")
coinstats_item.name = nil
assert_not coinstats_item.valid?
assert_includes coinstats_item.errors[:name], "can't be blank"
end
test "requires api_key to be present" do
coinstats_item = CoinstatsItem.new(family: @family, name: "Test")
coinstats_item.api_key = nil
assert_not coinstats_item.valid?
assert_includes coinstats_item.errors[:api_key], "can't be blank"
end
test "requires api_key to be present on update" do
@coinstats_item.api_key = ""
assert_not @coinstats_item.valid?
assert_includes @coinstats_item.errors[:api_key], "can't be blank"
end
test "scopes work correctly" do
# Create one for deletion
item_for_deletion = CoinstatsItem.create!(
family: @family,
name: "Delete Me",
api_key: "delete_key",
scheduled_for_deletion: true
)
active_items = CoinstatsItem.active
ordered_items = CoinstatsItem.ordered
assert_includes active_items, @coinstats_item
refute_includes active_items, item_for_deletion
assert_equal [ @coinstats_item, item_for_deletion ].sort_by(&:created_at).reverse,
ordered_items.to_a
end
test "needs_update scope returns items requiring update" do
@coinstats_item.update!(status: :requires_update)
good_item = CoinstatsItem.create!(
family: @family,
name: "Good Item",
api_key: "good_key"
)
needs_update_items = CoinstatsItem.needs_update
assert_includes needs_update_items, @coinstats_item
refute_includes needs_update_items, good_item
end
test "institution display name returns name when present" do
assert_equal "Test CoinStats Connection", @coinstats_item.institution_display_name
end
test "institution display name falls back to CoinStats" do
# Bypass validation by using update_column
@coinstats_item.update_column(:name, "")
assert_equal "CoinStats", @coinstats_item.institution_display_name
end
test "credentials_configured? returns true when api_key present" do
assert @coinstats_item.credentials_configured?
end
test "credentials_configured? returns false when api_key blank" do
@coinstats_item.api_key = nil
refute @coinstats_item.credentials_configured?
end
test "upserts coinstats snapshot" do
snapshot_data = {
total_balance: 5000.0,
wallets: [ { address: "0x123", blockchain: "ethereum" } ]
}
@coinstats_item.upsert_coinstats_snapshot!(snapshot_data)
@coinstats_item.reload
# Verify key data is stored correctly (keys may be string or symbol)
assert_equal 5000.0, @coinstats_item.raw_payload["total_balance"]
assert_equal 1, @coinstats_item.raw_payload["wallets"].count
assert_equal "0x123", @coinstats_item.raw_payload["wallets"].first["address"]
end
test "has_completed_initial_setup? returns false when no accounts" do
refute @coinstats_item.has_completed_initial_setup?
end
test "has_completed_initial_setup? returns true when accounts exist" do
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Crypto",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Test Wallet",
currency: "USD"
)
AccountProvider.create!(account: account, provider: coinstats_account)
assert @coinstats_item.has_completed_initial_setup?
end
test "linked_accounts_count returns count of accounts with provider links" do
# Initially no linked accounts
assert_equal 0, @coinstats_item.linked_accounts_count
# Create a linked account
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Crypto",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Test Wallet",
currency: "USD"
)
AccountProvider.create!(account: account, provider: coinstats_account)
assert_equal 1, @coinstats_item.linked_accounts_count
end
test "unlinked_accounts_count returns count of accounts without provider links" do
# Create an unlinked account
@coinstats_item.coinstats_accounts.create!(
name: "Unlinked Wallet",
currency: "USD"
)
assert_equal 1, @coinstats_item.unlinked_accounts_count
end
test "sync_status_summary shows no accounts message" do
assert_equal "No crypto wallets found", @coinstats_item.sync_status_summary
end
test "sync_status_summary shows all synced message" do
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Crypto",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Test Wallet",
currency: "USD"
)
AccountProvider.create!(account: account, provider: coinstats_account)
assert_equal "1 crypto wallet synced", @coinstats_item.sync_status_summary
end
test "sync_status_summary shows mixed status message" do
# Create a linked account
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Test Crypto",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Linked Wallet",
currency: "USD"
)
AccountProvider.create!(account: account, provider: coinstats_account)
# Create an unlinked account
@coinstats_item.coinstats_accounts.create!(
name: "Unlinked Wallet",
currency: "USD"
)
assert_equal "1 crypto wallets synced, 1 need setup", @coinstats_item.sync_status_summary
end
end

View File

@@ -0,0 +1,69 @@
require "test_helper"
class CurrencyNormalizableTest < ActiveSupport::TestCase
# Create a test class that includes the concern
class TestClass
include CurrencyNormalizable
# Expose private method for testing
def test_parse_currency(value)
parse_currency(value)
end
end
setup do
@parser = TestClass.new
end
test "parse_currency normalizes lowercase to uppercase" do
assert_equal "USD", @parser.test_parse_currency("usd")
assert_equal "EUR", @parser.test_parse_currency("eur")
assert_equal "GBP", @parser.test_parse_currency("gbp")
end
test "parse_currency handles whitespace" do
assert_equal "USD", @parser.test_parse_currency(" usd ")
assert_equal "EUR", @parser.test_parse_currency("\teur\n")
end
test "parse_currency returns nil for blank values" do
assert_nil @parser.test_parse_currency(nil)
assert_nil @parser.test_parse_currency("")
assert_nil @parser.test_parse_currency(" ")
end
test "parse_currency returns nil for invalid format" do
assert_nil @parser.test_parse_currency("US") # Too short
assert_nil @parser.test_parse_currency("USDD") # Too long
assert_nil @parser.test_parse_currency("123") # Numbers
assert_nil @parser.test_parse_currency("US1") # Mixed
end
test "parse_currency returns nil for XXX (no currency code)" do
# XXX is ISO 4217 for "no currency" but not valid for monetary operations
assert_nil @parser.test_parse_currency("XXX")
assert_nil @parser.test_parse_currency("xxx")
end
test "parse_currency returns nil for unknown 3-letter codes" do
# These are 3 letters but not recognized currencies
assert_nil @parser.test_parse_currency("ZZZ")
assert_nil @parser.test_parse_currency("ABC")
end
test "parse_currency accepts valid ISO currencies" do
# Common currencies
assert_equal "USD", @parser.test_parse_currency("USD")
assert_equal "EUR", @parser.test_parse_currency("EUR")
assert_equal "GBP", @parser.test_parse_currency("GBP")
assert_equal "JPY", @parser.test_parse_currency("JPY")
assert_equal "CHF", @parser.test_parse_currency("CHF")
assert_equal "CAD", @parser.test_parse_currency("CAD")
assert_equal "AUD", @parser.test_parse_currency("AUD")
# Less common but valid currencies
assert_equal "PLN", @parser.test_parse_currency("PLN")
assert_equal "SEK", @parser.test_parse_currency("SEK")
assert_equal "NOK", @parser.test_parse_currency("NOK")
end
end

View File

@@ -27,4 +27,40 @@ class Family::SyncerTest < ActiveSupport::TestCase
assert_equal "completed", family_sync.reload.status
end
test "only applies active rules during sync" do
family_sync = syncs(:family)
# Create an active rule
active_rule = @family.rules.create!(
resource_type: "transaction",
active: true,
actions: [ Rule::Action.new(action_type: "exclude_transaction") ]
)
# Create a disabled rule
disabled_rule = @family.rules.create!(
resource_type: "transaction",
active: false,
actions: [ Rule::Action.new(action_type: "exclude_transaction") ]
)
syncer = Family::Syncer.new(@family)
# Stub the relation to return our specific instances so expectations work
@family.rules.stubs(:where).with(active: true).returns([ active_rule ])
# Expect apply_later to be called only for the active rule
active_rule.expects(:apply_later).once
disabled_rule.expects(:apply_later).never
# Mock the account and plaid item syncs to avoid side effects
Account.any_instance.stubs(:sync_later)
PlaidItem.any_instance.stubs(:sync_later)
SimplefinItem.any_instance.stubs(:sync_later)
LunchflowItem.any_instance.stubs(:sync_later)
EnableBankingItem.any_instance.stubs(:sync_later)
syncer.perform_sync(family_sync)
end
end

View File

@@ -22,9 +22,10 @@ class IncomeStatementTest < ActiveSupport::TestCase
test "calculates totals for transactions" do
income_statement = IncomeStatement.new(@family)
assert_equal Money.new(1000, @family.currency), income_statement.totals.income_money
assert_equal Money.new(200 + 300 + 400, @family.currency), income_statement.totals.expense_money
assert_equal 4, income_statement.totals.transactions_count
totals = income_statement.totals(date_range: Period.last_30_days.date_range)
assert_equal Money.new(1000, @family.currency), totals.income_money
assert_equal Money.new(200 + 300 + 400, @family.currency), totals.expense_money
assert_equal 4, totals.transactions_count
end
test "calculates expenses for a period" do
@@ -157,7 +158,7 @@ class IncomeStatementTest < ActiveSupport::TestCase
inflow_transaction = create_transaction(account: @credit_card_account, amount: -500, kind: "funds_movement")
income_statement = IncomeStatement.new(@family)
totals = income_statement.totals
totals = income_statement.totals(date_range: Period.last_30_days.date_range)
# NOW WORKING: Excludes transfers correctly after refactoring
assert_equal 4, totals.transactions_count # Only original 4 transactions
@@ -170,7 +171,7 @@ class IncomeStatementTest < ActiveSupport::TestCase
loan_payment = create_transaction(account: @checking_account, amount: 1000, category: nil, kind: "loan_payment")
income_statement = IncomeStatement.new(@family)
totals = income_statement.totals
totals = income_statement.totals(date_range: Period.last_30_days.date_range)
# CONTINUES TO WORK: Includes loan payments as expenses (loan_payment not in exclusion list)
assert_equal 5, totals.transactions_count
@@ -183,7 +184,7 @@ class IncomeStatementTest < ActiveSupport::TestCase
one_time_transaction = create_transaction(account: @checking_account, amount: 250, category: @groceries_category, kind: "one_time")
income_statement = IncomeStatement.new(@family)
totals = income_statement.totals
totals = income_statement.totals(date_range: Period.last_30_days.date_range)
# NOW WORKING: Excludes one-time transactions correctly after refactoring
assert_equal 4, totals.transactions_count # Only original 4 transactions
@@ -196,7 +197,7 @@ class IncomeStatementTest < ActiveSupport::TestCase
payment_transaction = create_transaction(account: @checking_account, amount: 300, category: nil, kind: "cc_payment")
income_statement = IncomeStatement.new(@family)
totals = income_statement.totals
totals = income_statement.totals(date_range: Period.last_30_days.date_range)
# NOW WORKING: Excludes payment transactions correctly after refactoring
assert_equal 4, totals.transactions_count # Only original 4 transactions
@@ -210,7 +211,7 @@ class IncomeStatementTest < ActiveSupport::TestCase
excluded_transaction_entry.update!(excluded: true)
income_statement = IncomeStatement.new(@family)
totals = income_statement.totals
totals = income_statement.totals(date_range: Period.last_30_days.date_range)
# Should exclude excluded transactions
assert_equal 4, totals.transactions_count # Only original 4 transactions
@@ -278,7 +279,7 @@ class IncomeStatementTest < ActiveSupport::TestCase
create_transaction(account: @checking_account, amount: 150, category: nil)
income_statement = IncomeStatement.new(@family)
totals = income_statement.totals
totals = income_statement.totals(date_range: Period.last_30_days.date_range)
# Should still include uncategorized transaction in totals
assert_equal 5, totals.transactions_count

View File

@@ -0,0 +1,265 @@
require "test_helper"
class LunchflowAccount::Investments::HoldingsProcessorTest < ActiveSupport::TestCase
setup do
@lunchflow_account = lunchflow_accounts(:investment_account)
@account = accounts(:investment)
# Create account_provider to link lunchflow_account to account
@account_provider = AccountProvider.create!(
account: @account,
provider: @lunchflow_account
)
# Reload to ensure associations are loaded
@lunchflow_account.reload
end
test "creates holding records from Lunchflow holdings snapshot" do
# Verify setup is correct
assert_not_nil @lunchflow_account.current_account, "Account should be linked"
assert_equal "Investment", @lunchflow_account.current_account.accountable_type
# Use unique dates to avoid conflicts with existing fixture holdings
test_holdings_payload = [
{
"security" => {
"name" => "iShares Inc MSCI Brazil",
"currency" => "USD",
"tickerSymbol" => "NEWTEST1",
"figi" => nil,
"cusp" => nil,
"isin" => nil
},
"quantity" => 5,
"price" => 42.15,
"value" => 210.75,
"costBasis" => 100.0,
"currency" => "USD",
"raw" => {
"quiltt" => {
"id" => "hld_test_123"
}
}
},
{
"security" => {
"name" => "Test Security",
"currency" => "USD",
"tickerSymbol" => "NEWTEST2",
"figi" => nil,
"cusp" => nil,
"isin" => nil
},
"quantity" => 10,
"price" => 150.0,
"value" => 1500.0,
"costBasis" => 1200.0,
"currency" => "USD",
"raw" => {
"quiltt" => {
"id" => "hld_test_456"
}
}
}
]
@lunchflow_account.update!(raw_holdings_payload: test_holdings_payload)
processor = LunchflowAccount::Investments::HoldingsProcessor.new(@lunchflow_account)
assert_difference "Holding.count", 2 do
processor.process
end
holdings = Holding.where(account: @account).where.not(external_id: nil).order(:created_at)
assert_equal 2, holdings.count
assert_equal "USD", holdings.first.currency
assert_equal "lunchflow_hld_test_123", holdings.first.external_id
end
test "skips processing for non-investment accounts" do
# Create a depository account
depository_account = accounts(:depository)
depository_lunchflow_account = LunchflowAccount.create!(
lunchflow_item: lunchflow_items(:one),
account_id: "lf_depository",
name: "Depository",
currency: "USD"
)
AccountProvider.create!(
account: depository_account,
provider: depository_lunchflow_account
)
depository_lunchflow_account.reload
test_holdings_payload = [
{
"security" => { "name" => "Test", "tickerSymbol" => "TEST", "currency" => "USD" },
"quantity" => 10,
"price" => 100.0,
"value" => 1000.0,
"costBasis" => nil,
"currency" => "USD",
"raw" => { "quiltt" => { "id" => "hld_skip" } }
}
]
depository_lunchflow_account.update!(raw_holdings_payload: test_holdings_payload)
processor = LunchflowAccount::Investments::HoldingsProcessor.new(depository_lunchflow_account)
assert_no_difference "Holding.count" do
processor.process
end
end
test "creates synthetic ticker when tickerSymbol is missing" do
test_holdings_payload = [
{
"security" => {
"name" => "Custom 401k Fund",
"currency" => "USD",
"tickerSymbol" => nil,
"figi" => nil,
"cusp" => nil,
"isin" => nil
},
"quantity" => 100,
"price" => 50.0,
"value" => 5000.0,
"costBasis" => 4500.0,
"currency" => "USD",
"raw" => {
"quiltt" => {
"id" => "hld_custom_123"
}
}
}
]
@lunchflow_account.update!(raw_holdings_payload: test_holdings_payload)
processor = LunchflowAccount::Investments::HoldingsProcessor.new(@lunchflow_account)
assert_difference "Holding.count", 1 do
processor.process
end
holding = Holding.where(account: @account).where.not(external_id: nil).last
assert_equal "lunchflow_hld_custom_123", holding.external_id
assert_equal 100, holding.qty
assert_equal 5000.0, holding.amount
end
test "skips zero value holdings" do
test_holdings_payload = [
{
"security" => {
"name" => "Zero Position",
"currency" => "USD",
"tickerSymbol" => "ZERO",
"figi" => nil,
"cusp" => nil,
"isin" => nil
},
"quantity" => 0,
"price" => 0,
"value" => 0,
"costBasis" => nil,
"currency" => "USD",
"raw" => {
"quiltt" => {
"id" => "hld_zero"
}
}
}
]
@lunchflow_account.update!(raw_holdings_payload: test_holdings_payload)
Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl))
processor = LunchflowAccount::Investments::HoldingsProcessor.new(@lunchflow_account)
assert_no_difference "Holding.count" do
processor.process
end
end
test "handles empty holdings payload gracefully" do
@lunchflow_account.update!(raw_holdings_payload: [])
processor = LunchflowAccount::Investments::HoldingsProcessor.new(@lunchflow_account)
assert_no_difference "Holding.count" do
processor.process
end
end
test "handles nil holdings payload gracefully" do
@lunchflow_account.update!(raw_holdings_payload: nil)
processor = LunchflowAccount::Investments::HoldingsProcessor.new(@lunchflow_account)
assert_no_difference "Holding.count" do
processor.process
end
end
test "continues processing other holdings when one fails" do
test_holdings_payload = [
{
"security" => {
"name" => "Good Holding",
"currency" => "USD",
"tickerSymbol" => "GOODTEST",
"figi" => nil,
"cusp" => nil,
"isin" => nil
},
"quantity" => 10,
"price" => 100.0,
"value" => 1000.0,
"costBasis" => nil,
"currency" => "USD",
"raw" => {
"quiltt" => {
"id" => "hld_good"
}
}
},
{
"security" => {
"name" => nil, # This will cause it to skip (no name, no symbol)
"currency" => "USD",
"tickerSymbol" => nil,
"figi" => nil,
"cusp" => nil,
"isin" => nil
},
"quantity" => 5,
"price" => 50.0,
"value" => 250.0,
"costBasis" => nil,
"currency" => "USD",
"raw" => {
"quiltt" => {
"id" => "hld_bad"
}
}
}
]
@lunchflow_account.update!(raw_holdings_payload: test_holdings_payload)
processor = LunchflowAccount::Investments::HoldingsProcessor.new(@lunchflow_account)
# Should create 1 holding (the good one)
assert_difference "Holding.count", 1 do
processor.process
end
end
end

View File

@@ -4,8 +4,9 @@ require "ostruct"
class MarketDataImporterTest < ActiveSupport::TestCase
include ProviderTestHelper
SNAPSHOT_START_DATE = MarketDataImporter::SNAPSHOT_DAYS.days.ago.to_date
PROVIDER_BUFFER = 5.days
SNAPSHOT_START_DATE = MarketDataImporter::SNAPSHOT_DAYS.days.ago.to_date
SECURITY_PRICE_BUFFER = Security::Price::Importer::PROVISIONAL_LOOKBACK_DAYS.days
EXCHANGE_RATE_BUFFER = 5.days
setup do
Security::Price.delete_all
@@ -39,7 +40,7 @@ class MarketDataImporterTest < ActiveSupport::TestCase
date: SNAPSHOT_START_DATE,
rate: 0.5)
expected_start_date = (SNAPSHOT_START_DATE + 1.day) - PROVIDER_BUFFER
expected_start_date = (SNAPSHOT_START_DATE + 1.day) - EXCHANGE_RATE_BUFFER
end_date = Date.current.in_time_zone("America/New_York").to_date
@provider.expects(:fetch_exchange_rates)
@@ -70,7 +71,7 @@ class MarketDataImporterTest < ActiveSupport::TestCase
test "syncs security prices" do
security = Security.create!(ticker: "AAPL", exchange_operating_mic: "XNAS")
expected_start_date = SNAPSHOT_START_DATE - PROVIDER_BUFFER
expected_start_date = SNAPSHOT_START_DATE - SECURITY_PRICE_BUFFER
end_date = Date.current.in_time_zone("America/New_York").to_date
@provider.expects(:fetch_security_prices)

View File

@@ -0,0 +1,124 @@
require "test_helper"
class Provider::CoinstatsAdapterTest < ActiveSupport::TestCase
include ProviderAdapterTestInterface
setup do
@family = families(:dylan_family)
@coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats Bank",
api_key: "test_api_key_123"
)
@coinstats_account = CoinstatsAccount.create!(
coinstats_item: @coinstats_item,
name: "CoinStats Crypto Account",
account_id: "cs_mock_1",
currency: "USD",
current_balance: 1000,
institution_metadata: {
"name" => "CoinStats Test Wallet",
"domain" => "coinstats.app",
"url" => "https://coinstats.app",
"logo" => "https://example.com/logo.png"
}
)
@account = accounts(:crypto)
@adapter = Provider::CoinstatsAdapter.new(@coinstats_account, account: @account)
end
def adapter
@adapter
end
# Run shared interface tests
test_provider_adapter_interface
test_syncable_interface
test_institution_metadata_interface
# Provider-specific tests
test "returns correct provider name" do
assert_equal "coinstats", @adapter.provider_name
end
test "returns correct provider type" do
assert_equal "CoinstatsAccount", @adapter.provider_type
end
test "returns coinstats item" do
assert_equal @coinstats_account.coinstats_item, @adapter.item
end
test "returns account" do
assert_equal @account, @adapter.account
end
test "can_delete_holdings? returns false" do
assert_equal false, @adapter.can_delete_holdings?
end
test "parses institution domain from institution_metadata" do
assert_equal "coinstats.app", @adapter.institution_domain
end
test "parses institution name from institution_metadata" do
assert_equal "CoinStats Test Wallet", @adapter.institution_name
end
test "parses institution url from institution_metadata" do
assert_equal "https://coinstats.app", @adapter.institution_url
end
test "returns logo_url from institution_metadata" do
assert_equal "https://example.com/logo.png", @adapter.logo_url
end
test "derives domain from url if domain is blank" do
@coinstats_account.update!(institution_metadata: {
"url" => "https://www.example.com/path"
})
adapter = Provider::CoinstatsAdapter.new(@coinstats_account, account: @account)
assert_equal "example.com", adapter.institution_domain
end
test "supported_account_types includes Crypto" do
assert_includes Provider::CoinstatsAdapter.supported_account_types, "Crypto"
end
test "connection_configs returns configurations when family can connect" do
@family.stubs(:can_connect_coinstats?).returns(true)
configs = Provider::CoinstatsAdapter.connection_configs(family: @family)
assert_equal 1, configs.length
assert_equal "coinstats", configs.first[:key]
assert_equal "CoinStats", configs.first[:name]
assert configs.first[:can_connect]
end
test "connection_configs returns empty when family cannot connect" do
@family.stubs(:can_connect_coinstats?).returns(false)
configs = Provider::CoinstatsAdapter.connection_configs(family: @family)
assert_equal [], configs
end
test "build_provider returns nil when family is nil" do
result = Provider::CoinstatsAdapter.build_provider(family: nil)
assert_nil result
end
test "build_provider returns nil when no coinstats_items with api_key" do
empty_family = families(:empty)
result = Provider::CoinstatsAdapter.build_provider(family: empty_family)
assert_nil result
end
test "build_provider returns Provider::Coinstats when credentials configured" do
result = Provider::CoinstatsAdapter.build_provider(family: @family)
assert_instance_of Provider::Coinstats, result
end
end

View File

@@ -0,0 +1,164 @@
require "test_helper"
class Provider::CoinstatsTest < ActiveSupport::TestCase
setup do
@provider = Provider::Coinstats.new("test_api_key")
end
test "extract_wallet_balance finds matching wallet by address and connectionId" do
bulk_data = [
{
blockchain: "ethereum",
address: "0x123abc",
connectionId: "ethereum",
balances: [
{ coinId: "ethereum", name: "Ethereum", amount: 1.5, price: 2000 }
]
},
{
blockchain: "bitcoin",
address: "bc1qxyz",
connectionId: "bitcoin",
balances: [
{ coinId: "bitcoin", name: "Bitcoin", amount: 0.5, price: 50000 }
]
}
]
result = @provider.extract_wallet_balance(bulk_data, "0x123abc", "ethereum")
assert_equal 1, result.size
assert_equal "ethereum", result.first[:coinId]
end
test "extract_wallet_balance handles case insensitive matching" do
bulk_data = [
{
blockchain: "Ethereum",
address: "0x123ABC",
connectionId: "Ethereum",
balances: [
{ coinId: "ethereum", name: "Ethereum", amount: 1.5, price: 2000 }
]
}
]
result = @provider.extract_wallet_balance(bulk_data, "0x123abc", "ethereum")
assert_equal 1, result.size
assert_equal "ethereum", result.first[:coinId]
end
test "extract_wallet_balance returns empty array when wallet not found" do
bulk_data = [
{
blockchain: "ethereum",
address: "0x123abc",
connectionId: "ethereum",
balances: [
{ coinId: "ethereum", name: "Ethereum", amount: 1.5, price: 2000 }
]
}
]
result = @provider.extract_wallet_balance(bulk_data, "0xnotfound", "ethereum")
assert_equal [], result
end
test "extract_wallet_balance returns empty array for nil bulk_data" do
result = @provider.extract_wallet_balance(nil, "0x123abc", "ethereum")
assert_equal [], result
end
test "extract_wallet_balance returns empty array for non-array bulk_data" do
result = @provider.extract_wallet_balance({ error: "invalid" }, "0x123abc", "ethereum")
assert_equal [], result
end
test "extract_wallet_balance matches by blockchain when connectionId differs" do
bulk_data = [
{
blockchain: "ethereum",
address: "0x123abc",
connectionId: "eth-mainnet", # Different connectionId
balances: [
{ coinId: "ethereum", name: "Ethereum", amount: 1.5, price: 2000 }
]
}
]
result = @provider.extract_wallet_balance(bulk_data, "0x123abc", "ethereum")
assert_equal 1, result.size
end
test "extract_wallet_transactions finds matching wallet transactions" do
bulk_data = [
{
blockchain: "ethereum",
address: "0x123abc",
connectionId: "ethereum",
transactions: [
{ hash: { id: "0xtx1" }, type: "Received", date: "2025-01-01T10:00:00.000Z" },
{ hash: { id: "0xtx2" }, type: "Sent", date: "2025-01-02T11:00:00.000Z" }
]
},
{
blockchain: "bitcoin",
address: "bc1qxyz",
connectionId: "bitcoin",
transactions: [
{ hash: { id: "btctx1" }, type: "Received", date: "2025-01-03T12:00:00.000Z" }
]
}
]
result = @provider.extract_wallet_transactions(bulk_data, "0x123abc", "ethereum")
assert_equal 2, result.size
assert_equal "0xtx1", result.first[:hash][:id]
end
test "extract_wallet_transactions returns empty array when wallet not found" do
bulk_data = [
{
blockchain: "ethereum",
address: "0x123abc",
connectionId: "ethereum",
transactions: [
{ hash: { id: "0xtx1" }, type: "Received" }
]
}
]
result = @provider.extract_wallet_transactions(bulk_data, "0xnotfound", "ethereum")
assert_equal [], result
end
test "extract_wallet_transactions returns empty array for nil bulk_data" do
result = @provider.extract_wallet_transactions(nil, "0x123abc", "ethereum")
assert_equal [], result
end
test "extract_wallet_transactions handles case insensitive matching" do
bulk_data = [
{
blockchain: "Ethereum",
address: "0x123ABC",
connectionId: "Ethereum",
transactions: [
{ hash: { id: "0xtx1" }, type: "Received" }
]
}
]
result = @provider.extract_wallet_transactions(bulk_data, "0x123abc", "ethereum")
assert_equal 1, result.size
end
end

View File

@@ -0,0 +1,144 @@
require "test_helper"
class Provider::SimplefinTest < ActiveSupport::TestCase
setup do
@provider = Provider::Simplefin.new
@access_url = "https://example.com/simplefin/access"
end
test "retries on Net::ReadTimeout and succeeds on retry" do
# First call raises timeout, second call succeeds
mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}')
HTTParty.expects(:get)
.times(2)
.raises(Net::ReadTimeout.new("Connection timed out"))
.then.returns(mock_response)
# Stub sleep to avoid actual delays in tests
@provider.stubs(:sleep)
result = @provider.get_accounts(@access_url)
assert_equal({ accounts: [] }, result)
end
test "retries on Net::OpenTimeout and succeeds on retry" do
mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}')
HTTParty.expects(:get)
.times(2)
.raises(Net::OpenTimeout.new("Connection timed out"))
.then.returns(mock_response)
@provider.stubs(:sleep)
result = @provider.get_accounts(@access_url)
assert_equal({ accounts: [] }, result)
end
test "retries on SocketError and succeeds on retry" do
mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}')
HTTParty.expects(:get)
.times(2)
.raises(SocketError.new("Failed to open TCP connection"))
.then.returns(mock_response)
@provider.stubs(:sleep)
result = @provider.get_accounts(@access_url)
assert_equal({ accounts: [] }, result)
end
test "raises SimplefinError after max retries exceeded" do
HTTParty.expects(:get)
.times(4) # Initial + 3 retries
.raises(Net::ReadTimeout.new("Connection timed out"))
@provider.stubs(:sleep)
error = assert_raises(Provider::Simplefin::SimplefinError) do
@provider.get_accounts(@access_url)
end
assert_equal :network_error, error.error_type
assert_match(/Network error after 3 retries/, error.message)
end
test "does not retry on non-retryable errors" do
HTTParty.expects(:get)
.times(1)
.raises(ArgumentError.new("Invalid argument"))
error = assert_raises(Provider::Simplefin::SimplefinError) do
@provider.get_accounts(@access_url)
end
assert_equal :request_failed, error.error_type
end
test "handles HTTP 429 rate limit response" do
mock_response = OpenStruct.new(code: 429, body: "Rate limit exceeded")
HTTParty.expects(:get).returns(mock_response)
error = assert_raises(Provider::Simplefin::SimplefinError) do
@provider.get_accounts(@access_url)
end
assert_equal :rate_limited, error.error_type
assert_match(/rate limit exceeded/i, error.message)
end
test "handles HTTP 500 server error response" do
mock_response = OpenStruct.new(code: 500, body: "Internal Server Error")
HTTParty.expects(:get).returns(mock_response)
error = assert_raises(Provider::Simplefin::SimplefinError) do
@provider.get_accounts(@access_url)
end
assert_equal :server_error, error.error_type
end
test "claim_access_url retries on network errors" do
setup_token = Base64.encode64("https://example.com/claim")
mock_response = OpenStruct.new(code: 200, body: "https://example.com/access")
HTTParty.expects(:post)
.times(2)
.raises(Net::ReadTimeout.new("Connection timed out"))
.then.returns(mock_response)
@provider.stubs(:sleep)
result = @provider.claim_access_url(setup_token)
assert_equal "https://example.com/access", result
end
test "exponential backoff delay increases with retries" do
provider = Provider::Simplefin.new
# Access private method for testing
delay1 = provider.send(:calculate_retry_delay, 1)
delay2 = provider.send(:calculate_retry_delay, 2)
delay3 = provider.send(:calculate_retry_delay, 3)
# Delays should increase (accounting for jitter)
# Base delays: 2, 4, 8 seconds (with up to 25% jitter)
assert delay1 >= 2 && delay1 <= 2.5, "First retry delay should be ~2s"
assert delay2 >= 4 && delay2 <= 5, "Second retry delay should be ~4s"
assert delay3 >= 8 && delay3 <= 10, "Third retry delay should be ~8s"
end
test "retry delay is capped at MAX_RETRY_DELAY" do
provider = Provider::Simplefin.new
# Test with a high retry count that would exceed max delay
delay = provider.send(:calculate_retry_delay, 10)
assert delay <= Provider::Simplefin::MAX_RETRY_DELAY,
"Delay should be capped at MAX_RETRY_DELAY (#{Provider::Simplefin::MAX_RETRY_DELAY}s)"
end
end

View File

@@ -201,4 +201,39 @@ class RuleTest < ActiveSupport::TestCase
assert_equal business_category, transaction_entry.transaction.category, "Transaction with 'business' in notes should be categorized"
assert_nil transaction_entry2.transaction.category, "Transaction without 'business' in notes should not be categorized"
end
test "total_affected_resource_count deduplicates overlapping rules" do
# Create transactions
transaction_entry1 = create_transaction(date: Date.current, account: @account, name: "Whole Foods", amount: 50)
transaction_entry2 = create_transaction(date: Date.current, account: @account, name: "Whole Foods", amount: 100)
transaction_entry3 = create_transaction(date: Date.current, account: @account, name: "Target", amount: 75)
# Rule 1: Match transactions with name "Whole Foods" (matches txn 1 and 2)
rule1 = Rule.create!(
family: @family,
resource_type: "transaction",
effective_date: 1.day.ago.to_date,
conditions: [ Rule::Condition.new(condition_type: "transaction_name", operator: "like", value: "Whole Foods") ],
actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @groceries_category.id) ]
)
# Rule 2: Match transactions with amount > 60 (matches txn 2 and 3)
rule2 = Rule.create!(
family: @family,
resource_type: "transaction",
effective_date: 1.day.ago.to_date,
conditions: [ Rule::Condition.new(condition_type: "transaction_amount", operator: ">", value: 60) ],
actions: [ Rule::Action.new(action_type: "set_transaction_category", value: @groceries_category.id) ]
)
# Rule 1 affects 2 transactions, Rule 2 affects 2 transactions
# But transaction_entry2 is matched by both, so total unique should be 3
assert_equal 2, rule1.affected_resource_count
assert_equal 2, rule2.affected_resource_count
assert_equal 3, Rule.total_affected_resource_count([ rule1, rule2 ])
end
test "total_affected_resource_count returns zero for empty rules" do
assert_equal 0, Rule.total_affected_resource_count([])
end
end

View File

@@ -136,8 +136,284 @@ class Security::Price::ImporterTest < ActiveSupport::TestCase
assert_equal 1, Security::Price.count
end
test "marks prices as not provisional when from provider" do
Security::Price.delete_all
provider_response = provider_success_response([
OpenStruct.new(security: @security, date: 1.day.ago.to_date, price: 150, currency: "USD"),
OpenStruct.new(security: @security, date: Date.current, price: 155, currency: "USD")
])
@provider.expects(:fetch_security_prices)
.with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic,
start_date: get_provider_fetch_start_date(1.day.ago.to_date), end_date: Date.current)
.returns(provider_response)
Security::Price::Importer.new(
security: @security,
security_provider: @provider,
start_date: 1.day.ago.to_date,
end_date: Date.current
).import_provider_prices
db_prices = Security::Price.where(security: @security).order(:date)
assert db_prices.all? { |p| p.provisional == false }, "All prices from provider should not be provisional"
end
test "marks gap-filled weekend prices as provisional" do
Security::Price.delete_all
# Find a recent Saturday
saturday = Date.current
saturday -= 1.day until saturday.saturday?
friday = saturday - 1.day
# Provider only returns Friday's price, not Saturday
provider_response = provider_success_response([
OpenStruct.new(security: @security, date: friday, price: 150, currency: "USD")
])
@provider.expects(:fetch_security_prices)
.with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic,
start_date: get_provider_fetch_start_date(friday), end_date: saturday)
.returns(provider_response)
Security::Price::Importer.new(
security: @security,
security_provider: @provider,
start_date: friday,
end_date: saturday
).import_provider_prices
saturday_price = Security::Price.find_by(security: @security, date: saturday)
# Weekend gap-filled prices are now provisional so they can be fixed
# via cascade when the next weekday sync fetches the correct Friday price
assert saturday_price.provisional, "Weekend gap-filled price should be provisional"
end
test "marks gap-filled recent weekday prices as provisional" do
Security::Price.delete_all
# Find a recent weekday that's not today
weekday = 1.day.ago.to_date
weekday -= 1.day while weekday.saturday? || weekday.sunday?
# Start from 2 days before the weekday
start_date = weekday - 1.day
start_date -= 1.day while start_date.saturday? || start_date.sunday?
# Provider only returns start_date price, not the weekday
provider_response = provider_success_response([
OpenStruct.new(security: @security, date: start_date, price: 150, currency: "USD")
])
@provider.expects(:fetch_security_prices)
.with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic,
start_date: get_provider_fetch_start_date(start_date), end_date: weekday)
.returns(provider_response)
Security::Price::Importer.new(
security: @security,
security_provider: @provider,
start_date: start_date,
end_date: weekday
).import_provider_prices
weekday_price = Security::Price.find_by(security: @security, date: weekday)
# Only recent weekdays should be provisional
if weekday >= 3.days.ago.to_date
assert weekday_price.provisional, "Gap-filled recent weekday price should be provisional"
else
assert_not weekday_price.provisional, "Gap-filled old weekday price should not be provisional"
end
end
test "retries fetch when refetchable provisional prices exist" do
Security::Price.delete_all
# Skip if today is a weekend
return if Date.current.saturday? || Date.current.sunday?
# Pre-populate with provisional price for today
Security::Price.create!(
security: @security,
date: Date.current,
price: 100,
currency: "USD",
provisional: true
)
# Provider now returns today's actual price
provider_response = provider_success_response([
OpenStruct.new(security: @security, date: Date.current, price: 165, currency: "USD")
])
@provider.expects(:fetch_security_prices)
.with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic,
start_date: get_provider_fetch_start_date(Date.current), end_date: Date.current)
.returns(provider_response)
Security::Price::Importer.new(
security: @security,
security_provider: @provider,
start_date: Date.current,
end_date: Date.current
).import_provider_prices
db_price = Security::Price.find_by(security: @security, date: Date.current)
assert_equal 165, db_price.price, "Price should be updated from provider"
assert_not db_price.provisional, "Price should no longer be provisional after provider returns real price"
end
test "skips fetch when all prices are non-provisional" do
Security::Price.delete_all
# Create non-provisional prices for the range
(3.days.ago.to_date..Date.current).each_with_index do |date, idx|
Security::Price.create!(security: @security, date: date, price: 100 + idx, currency: "USD", provisional: false)
end
@provider.expects(:fetch_security_prices).never
Security::Price::Importer.new(
security: @security,
security_provider: @provider,
start_date: 3.days.ago.to_date,
end_date: Date.current
).import_provider_prices
end
test "does not mark old gap-filled prices as provisional" do
Security::Price.delete_all
# Use dates older than the lookback window
old_date = 10.days.ago.to_date
old_date -= 1.day while old_date.saturday? || old_date.sunday?
start_date = old_date - 1.day
start_date -= 1.day while start_date.saturday? || start_date.sunday?
# Provider only returns start_date price
provider_response = provider_success_response([
OpenStruct.new(security: @security, date: start_date, price: 150, currency: "USD")
])
@provider.expects(:fetch_security_prices)
.with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic,
start_date: get_provider_fetch_start_date(start_date), end_date: old_date)
.returns(provider_response)
Security::Price::Importer.new(
security: @security,
security_provider: @provider,
start_date: start_date,
end_date: old_date
).import_provider_prices
old_price = Security::Price.find_by(security: @security, date: old_date)
assert_not old_price.provisional, "Old gap-filled price should not be provisional"
end
test "provisional weekend prices get fixed via cascade from Friday" do
Security::Price.delete_all
# Find a recent Monday
monday = Date.current
monday += 1.day until monday.monday?
friday = monday - 3.days
saturday = monday - 2.days
sunday = monday - 1.day
travel_to monday do
# Create provisional weekend prices with WRONG values (simulating stale data)
Security::Price.create!(security: @security, date: saturday, price: 50, currency: "USD", provisional: true)
Security::Price.create!(security: @security, date: sunday, price: 50, currency: "USD", provisional: true)
# Provider returns Friday and Monday prices, but NOT weekend (markets closed)
provider_response = provider_success_response([
OpenStruct.new(security: @security, date: friday, price: 150, currency: "USD"),
OpenStruct.new(security: @security, date: monday, price: 155, currency: "USD")
])
@provider.expects(:fetch_security_prices).returns(provider_response)
Security::Price::Importer.new(
security: @security,
security_provider: @provider,
start_date: friday,
end_date: monday
).import_provider_prices
# Friday should have real price from provider
friday_price = Security::Price.find_by(security: @security, date: friday)
assert_equal 150, friday_price.price
assert_not friday_price.provisional, "Friday should not be provisional (real price)"
# Saturday should be gap-filled from Friday (150), not old wrong value (50)
saturday_price = Security::Price.find_by(security: @security, date: saturday)
assert_equal 150, saturday_price.price, "Saturday should use Friday's price via cascade"
assert saturday_price.provisional, "Saturday should be provisional (gap-filled)"
# Sunday should be gap-filled from Saturday (150)
sunday_price = Security::Price.find_by(security: @security, date: sunday)
assert_equal 150, sunday_price.price, "Sunday should use Friday's price via cascade"
assert sunday_price.provisional, "Sunday should be provisional (gap-filled)"
# Monday should have real price from provider
monday_price = Security::Price.find_by(security: @security, date: monday)
assert_equal 155, monday_price.price
assert_not monday_price.provisional, "Monday should not be provisional (real price)"
end
end
test "uses recent prices for gap-fill when effective_start_date skips old dates" do
Security::Price.delete_all
# Use travel_to to ensure we're on a weekday for consistent test behavior
# Find the next weekday if today is a weekend
test_date = Date.current
test_date += 1.day while test_date.saturday? || test_date.sunday?
travel_to test_date do
# Simulate: old price exists from first trade date (30 days ago) with STALE value
old_date = 30.days.ago.to_date
stale_price = 50
# Fully populate DB from old_date through yesterday so effective_start_date = today
# Use stale price for old dates, then recent price for recent dates
(old_date..1.day.ago.to_date).each do |date|
# Use stale price for dates older than lookback window, recent price for recent dates
price = date < 7.days.ago.to_date ? stale_price : 150
Security::Price.create!(security: @security, date: date, price: price, currency: "USD")
end
# Provider returns yesterday's price (155) - DIFFERENT from DB (150) to prove we use provider
# Provider does NOT return today (simulating market closed)
provider_response = provider_success_response([
OpenStruct.new(security: @security, date: 1.day.ago.to_date, price: 155, currency: "USD")
])
@provider.expects(:fetch_security_prices).returns(provider_response)
Security::Price::Importer.new(
security: @security,
security_provider: @provider,
start_date: old_date,
end_date: Date.current
).import_provider_prices
today_price = Security::Price.find_by(security: @security, date: Date.current)
# effective_start_date should be today (only missing date)
# start_price_value should use provider's yesterday (155), not stale old DB price (50)
# Today should gap-fill from that recent price
assert_equal 155, today_price.price, "Gap-fill should use recent provider price, not stale old price"
# Should be provisional since gap-filled for recent weekday
assert today_price.provisional, "Current weekday gap-filled price should be provisional"
end
end
private
def get_provider_fetch_start_date(start_date)
start_date - 5.days
start_date - Security::Price::Importer::PROVISIONAL_LOOKBACK_DAYS.days
end
end

View File

@@ -0,0 +1,99 @@
require "test_helper"
class SimplefinAccount::Liabilities::OverpaymentAnalyzerTest < ActiveSupport::TestCase
# Limit fixtures to only what's required to avoid FK validation on unrelated tables
fixtures :families
setup do
@family = families(:dylan_family)
@item = SimplefinItem.create!(family: @family, name: "SimpleFIN", access_url: "https://example.com/token")
@sfa = SimplefinAccount.create!(
simplefin_item: @item,
name: "Test Credit Card",
account_id: "cc_txn_window_1",
currency: "USD",
account_type: "credit",
current_balance: BigDecimal("-22.72")
)
# Avoid crosssuite fixture dependency by creating a fresh credit card account
@acct = Account.create!(
family: @family,
name: "Test CC",
balance: 0,
cash_balance: 0,
currency: "USD",
accountable: CreditCard.new
)
# Create explicit provider link to ensure FK validity in isolation
AccountProvider.create!(account: @acct, provider: @sfa)
# Enable heuristic
Setting["simplefin_cc_overpayment_detection"] = "true"
# Loosen thresholds for focused unit tests
Setting["simplefin_cc_overpayment_min_txns"] = "1"
Setting["simplefin_cc_overpayment_min_payments"] = "1"
Setting["simplefin_cc_overpayment_statement_guard_days"] = "0"
end
teardown do
# Disable heuristic to avoid bleeding into other tests
Setting["simplefin_cc_overpayment_detection"] = nil
Setting["simplefin_cc_overpayment_min_txns"] = nil
Setting["simplefin_cc_overpayment_min_payments"] = nil
Setting["simplefin_cc_overpayment_statement_guard_days"] = nil
begin
Rails.cache.delete_matched("simplefin:sfa:#{@sfa.id}:liability_sign_hint") if @sfa&.id
rescue
# ignore cache backends without delete_matched
end
# Ensure created records are removed to avoid FK validation across examples in single-file runs
AccountProvider.where(account_id: @acct.id).destroy_all rescue nil
@acct.destroy! rescue nil
@sfa.destroy! rescue nil
@item.destroy! rescue nil
end
test "classifies credit when payments exceed charges roughly by observed amount" do
# Create transactions in Maybe convention for liabilities:
# charges/spend: positive; payments: negative
# Observed abs is 22.72; make payments exceed charges by ~22.72
@acct.entries.delete_all
@acct.entries.create!(date: 10.days.ago.to_date, name: "Store A", amount: 50, currency: "USD", entryable: Transaction.new)
# Ensure payments exceed charges by at least observed.abs (~22.72)
@acct.entries.create!(date: 8.days.ago.to_date, name: "Payment", amount: -75, currency: "USD", entryable: Transaction.new)
result = SimplefinAccount::Liabilities::OverpaymentAnalyzer.new(@sfa, observed_balance: @sfa.current_balance).call
assert_equal :credit, result.classification, "expected classification to be credit"
end
test "classifies debt when charges exceed payments" do
@acct.entries.delete_all
@acct.entries.create!(date: 12.days.ago.to_date, name: "Groceries", amount: 120, currency: "USD", entryable: Transaction.new)
@acct.entries.create!(date: 11.days.ago.to_date, name: "Coffee", amount: 10, currency: "USD", entryable: Transaction.new)
@acct.entries.create!(date: 9.days.ago.to_date, name: "Payment", amount: -50, currency: "USD", entryable: Transaction.new)
result = SimplefinAccount::Liabilities::OverpaymentAnalyzer.new(@sfa, observed_balance: BigDecimal("-80")).call
assert_equal :debt, result.classification, "expected classification to be debt"
end
test "returns unknown when insufficient transactions" do
@acct.entries.delete_all
@acct.entries.create!(date: 5.days.ago.to_date, name: "Small", amount: 1, currency: "USD", entryable: Transaction.new)
result = SimplefinAccount::Liabilities::OverpaymentAnalyzer.new(@sfa, observed_balance: BigDecimal("-5")).call
assert_equal :unknown, result.classification
end
test "fallback to raw payload when no entries present" do
@acct.entries.delete_all
# Provide raw transactions in provider convention (expenses negative, income positive)
# We must negate in analyzer to convert to Maybe convention.
@sfa.update!(raw_transactions_payload: [
{ id: "t1", amount: -100, posted: (10.days.ago.to_date.to_s) }, # charge (-> +100)
{ id: "t2", amount: 150, posted: (8.days.ago.to_date.to_s) } # payment (-> -150)
])
result = SimplefinAccount::Liabilities::OverpaymentAnalyzer.new(@sfa, observed_balance: BigDecimal("-50")).call
assert_equal :credit, result.classification
end
end

View File

@@ -0,0 +1,277 @@
require "test_helper"
class SimplefinAccount::Transactions::ProcessorInvestmentTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
# Create SimpleFIN connection
@simplefin_item = SimplefinItem.create!(
family: @family,
name: "Test SimpleFIN",
access_url: "https://example.com/access"
)
# Create an Investment account
@account = Account.create!(
family: @family,
name: "Retirement - Roth IRA",
currency: "USD",
balance: 12199.06,
accountable: Investment.create!(subtype: :roth_ira)
)
# Create SimpleFIN account linked to the Investment account
@simplefin_account = SimplefinAccount.create!(
simplefin_item: @simplefin_item,
name: "Roth IRA",
account_id: "ACT-investment-123",
currency: "USD",
account_type: "investment",
current_balance: 12199.06,
raw_transactions_payload: [
{
"id" => "TRN-921a8cdb-f331-48ee-9de2-b0b9ff1d316a",
"posted" => 1766417520,
"amount" => "1.49",
"description" => "Dividend Reinvestment",
"payee" => "Dividend",
"memo" => "Dividend Reinvestment",
"transacted_at" => 1766417520
},
{
"id" => "TRN-881f2417-29e3-43f9-bd1b-013e60ba7a4b",
"posted" => 1766113200,
"amount" => "1.49",
"description" => "Sweep of dividend payouts",
"payee" => "Dividend",
"memo" => "Dividend Payment - IEMG",
"transacted_at" => 1766113200
},
{
"id" => "TRN-e52f1326-bbb6-42a7-8148-be48c8a81832",
"posted" => 1765985220,
"amount" => "0.05",
"description" => "Dividend Reinvestment",
"payee" => "Dividend",
"memo" => "Dividend Reinvestment",
"transacted_at" => 1765985220
}
]
)
# Link the account via legacy FK
@account.update!(simplefin_account_id: @simplefin_account.id)
end
test "processes dividend transactions for investment accounts" do
assert_equal 0, @account.entries.count, "Should start with no entries"
# Process transactions
processor = SimplefinAccount::Transactions::Processor.new(@simplefin_account)
processor.process
# Verify all 3 dividend transactions were created
assert_equal 3, @account.entries.count, "Should create 3 entries for dividend transactions"
# Verify entries are Transaction type (not Trade)
@account.entries.each do |entry|
assert_equal "Transaction", entry.entryable_type
end
# Verify external_ids are set correctly
external_ids = @account.entries.pluck(:external_id).sort
expected_ids = [
"simplefin_TRN-921a8cdb-f331-48ee-9de2-b0b9ff1d316a",
"simplefin_TRN-881f2417-29e3-43f9-bd1b-013e60ba7a4b",
"simplefin_TRN-e52f1326-bbb6-42a7-8148-be48c8a81832"
].sort
assert_equal expected_ids, external_ids
# Verify source is simplefin
@account.entries.each do |entry|
assert_equal "simplefin", entry.source
end
end
test "investment transactions processor is no-op to avoid duplicate processing" do
# First, process with regular processor
SimplefinAccount::Transactions::Processor.new(@simplefin_account).process
initial_count = @account.entries.count
assert_equal 3, initial_count
# Get the first entry's updated_at before running investment processor
first_entry = @account.entries.first
original_updated_at = first_entry.updated_at
# Run the investment transactions processor - should be a no-op
SimplefinAccount::Investments::TransactionsProcessor.new(@simplefin_account).process
# Entry count should be unchanged
assert_equal initial_count, @account.entries.reload.count
# Entries should not have been modified
first_entry.reload
assert_equal original_updated_at, first_entry.updated_at
end
test "processes transactions correctly via SimplefinAccount::Processor for investment accounts" do
# Verify the full processor flow works for investment accounts
processor = SimplefinAccount::Processor.new(@simplefin_account)
processor.process
# Should create transaction entries
assert_equal 3, @account.entries.where(entryable_type: "Transaction").count
# Verify amounts are correctly negated (SimpleFIN positive = income = negative in Sure)
entry = @account.entries.find_by(external_id: "simplefin_TRN-921a8cdb-f331-48ee-9de2-b0b9ff1d316a")
assert_not_nil entry
assert_equal BigDecimal("-1.49"), entry.amount
end
test "logs appropriate messages during processing" do
# Capture log output
log_output = StringIO.new
original_logger = Rails.logger
Rails.logger = Logger.new(log_output)
SimplefinAccount::Transactions::Processor.new(@simplefin_account).process
Rails.logger = original_logger
log_content = log_output.string
# Should log start message with transaction count
assert_match(/Processing 3 transactions/, log_content)
# Should log completion message
assert_match(/Completed.*3 processed, 0 errors/, log_content)
end
test "handles empty raw_transactions_payload gracefully" do
@simplefin_account.update!(raw_transactions_payload: [])
# Should not raise an error
processor = SimplefinAccount::Transactions::Processor.new(@simplefin_account)
processor.process
assert_equal 0, @account.entries.count
end
test "handles nil raw_transactions_payload gracefully" do
@simplefin_account.update!(raw_transactions_payload: nil)
# Should not raise an error
processor = SimplefinAccount::Transactions::Processor.new(@simplefin_account)
processor.process
assert_equal 0, @account.entries.count
end
test "repairs stale linkage when user re-adds institution in SimpleFIN" do
# Simulate user re-adding institution: old SimplefinAccount is linked but has no transactions,
# new SimplefinAccount is unlinked but has transactions
# Make the original account "stale" (no transactions)
@simplefin_account.update!(raw_transactions_payload: [])
# Create a "new" SimplefinAccount with the same name but different account_id
# This simulates what happens when SimpleFIN generates new IDs after re-adding
new_simplefin_account = SimplefinAccount.create!(
simplefin_item: @simplefin_item,
name: "Roth IRA", # Same name as original
account_id: "ACT-investment-456-NEW", # New ID
currency: "USD",
account_type: "investment",
current_balance: 12199.06,
raw_transactions_payload: [
{
"id" => "TRN-new-transaction-001",
"posted" => 1766417520,
"amount" => "5.00",
"description" => "New Dividend",
"payee" => "Dividend",
"memo" => "New Dividend Payment"
}
]
)
# New account is NOT linked (this is the problem we're fixing)
assert_nil new_simplefin_account.account
# Before repair: @simplefin_account is linked (but stale), new_simplefin_account is unlinked
assert_equal @simplefin_account.id, @account.reload.simplefin_account_id
# Process accounts - should repair the stale linkage
@simplefin_item.process_accounts
# After repair: new_simplefin_account should be linked
@account.reload
assert_equal new_simplefin_account.id, @account.simplefin_account_id, "Expected linkage to transfer to new_simplefin_account (#{new_simplefin_account.id}) but got #{@account.simplefin_account_id}"
# Old SimplefinAccount should still exist but be cleared of data
@simplefin_account.reload
assert_equal [], @simplefin_account.raw_transactions_payload
# Transaction from new SimplefinAccount should be created
assert_equal 1, @account.entries.count
entry = @account.entries.first
assert_equal "simplefin_TRN-new-transaction-001", entry.external_id
assert_equal BigDecimal("-5.00"), entry.amount
end
test "does not repair linkage when names dont match" do
# Make original stale
@simplefin_account.update!(raw_transactions_payload: [])
# Create new with DIFFERENT name
new_simplefin_account = SimplefinAccount.create!(
simplefin_item: @simplefin_item,
name: "Different Account Name", # Different name
account_id: "ACT-different-456",
currency: "USD",
account_type: "investment",
current_balance: 1000.00,
raw_transactions_payload: [
{ "id" => "TRN-different", "posted" => 1766417520, "amount" => "10.00", "description" => "Test" }
]
)
original_linkage = @account.simplefin_account_id
@simplefin_item.process_accounts
# Should NOT have transferred linkage because names don't match
@account.reload
assert_equal original_linkage, @account.simplefin_account_id
assert_equal 0, @account.entries.count
end
test "repairs linkage and merges transactions when both old and new have data" do
# Both accounts have transactions - repair should still happen and merge them
assert @simplefin_account.raw_transactions_payload.any?
# Create new with same name
new_simplefin_account = SimplefinAccount.create!(
simplefin_item: @simplefin_item,
name: "Roth IRA",
account_id: "ACT-investment-456-NEW",
currency: "USD",
account_type: "investment",
current_balance: 12199.06,
raw_transactions_payload: [
{ "id" => "TRN-new", "posted" => 1766417520, "amount" => "5.00", "description" => "New" }
]
)
@simplefin_item.process_accounts
# Should transfer linkage to new account (repair by name match)
@account.reload
assert_equal new_simplefin_account.id, @account.simplefin_account_id
# Transactions should be merged: 3 from old + 1 from new = 4 total
assert_equal 4, @account.entries.count
# Old account should be cleared
@simplefin_account.reload
assert_equal [], @simplefin_account.raw_transactions_payload
end
end

View File

@@ -81,4 +81,101 @@ class SimplefinAccountProcessorTest < ActiveSupport::TestCase
assert_equal BigDecimal("-75.00"), acct.reload.balance
end
test "liability debt with both fields negative becomes positive (you owe)" do
sfin_acct = SimplefinAccount.create!(
simplefin_item: @item,
name: "BofA Visa",
account_id: "cc_bofa_1",
currency: "USD",
account_type: "credit",
current_balance: BigDecimal("-1200"),
available_balance: BigDecimal("-5000")
)
acct = accounts(:credit_card)
acct.update!(simplefin_account: sfin_acct)
SimplefinAccount::Processor.new(sfin_acct).send(:process_account!)
assert_equal BigDecimal("1200"), acct.reload.balance
end
test "liability overpayment with both fields positive becomes negative (credit)" do
sfin_acct = SimplefinAccount.create!(
simplefin_item: @item,
name: "BofA Visa",
account_id: "cc_bofa_2",
currency: "USD",
account_type: "credit",
current_balance: BigDecimal("75"),
available_balance: BigDecimal("5000")
)
acct = accounts(:credit_card)
acct.update!(simplefin_account: sfin_acct)
SimplefinAccount::Processor.new(sfin_acct).send(:process_account!)
assert_equal BigDecimal("-75"), acct.reload.balance
end
test "mixed signs falls back to invert observed (balance positive, avail negative => negative)" do
sfin_acct = SimplefinAccount.create!(
simplefin_item: @item,
name: "Chase Freedom",
account_id: "cc_chase_1",
currency: "USD",
account_type: "credit",
current_balance: BigDecimal("50"),
available_balance: BigDecimal("-5000")
)
acct = accounts(:credit_card)
acct.update!(simplefin_account: sfin_acct)
SimplefinAccount::Processor.new(sfin_acct).send(:process_account!)
assert_equal BigDecimal("-50"), acct.reload.balance
end
test "only available-balance present positive → negative (credit) for liability" do
sfin_acct = SimplefinAccount.create!(
simplefin_item: @item,
name: "Chase Visa",
account_id: "cc_chase_2",
currency: "USD",
account_type: "credit",
current_balance: nil,
available_balance: BigDecimal("25")
)
acct = accounts(:credit_card)
acct.update!(simplefin_account: sfin_acct)
SimplefinAccount::Processor.new(sfin_acct).send(:process_account!)
assert_equal BigDecimal("-25"), acct.reload.balance
end
test "mislinked as asset but mapper infers credit → normalize as liability" do
sfin_acct = SimplefinAccount.create!(
simplefin_item: @item,
name: "Visa Signature",
account_id: "cc_mislinked",
currency: "USD",
account_type: "credit",
current_balance: BigDecimal("100.00"),
available_balance: BigDecimal("5000.00")
)
# Link to an asset account intentionally
acct = accounts(:depository)
acct.update!(simplefin_account: sfin_acct)
SimplefinAccount::Processor.new(sfin_acct).send(:process_account!)
# Mapper should infer liability from name; final should be negative
assert_equal BigDecimal("-100.00"), acct.reload.balance
end
end

View File

@@ -53,7 +53,9 @@ class SimplefinEntry::ProcessorTest < ActiveSupport::TestCase
assert_equal "Order #1234", sf["description"]
assert_equal({ "category" => "restaurants", "check_number" => nil }, sf["extra"])
end
test "flags pending transaction when posted is nil and transacted_at present" do
test "does not flag pending when posted is nil but provider pending flag not set" do
# Previously we inferred pending from missing posted date, but this was too aggressive -
# some providers don't supply posted dates even for settled transactions
tx = {
id: "tx_pending_1",
amount: "-20.00",
@@ -70,7 +72,7 @@ class SimplefinEntry::ProcessorTest < ActiveSupport::TestCase
entry = @account.entries.find_by!(external_id: "simplefin_tx_pending_1", source: "simplefin")
sf = entry.transaction.extra.fetch("simplefin")
assert_equal true, sf["pending"], "expected pending flag to be true"
assert_equal false, sf["pending"], "expected pending flag to be false when provider doesn't explicitly set pending"
end
test "captures FX metadata when tx currency differs from account currency" do

View File

@@ -19,37 +19,33 @@ class SimplefinItem::ImporterInactiveTest < ActiveSupport::TestCase
assert stats.dig("inactive", "a1"), "should be inactive when closed flag present"
end
test "marks inactive after three consecutive zero runs with no holdings" do
test "counts zero runs once per sync even with multiple imports" do
account_data = { id: "a2", name: "Dormant", balance: 0, "available-balance": 0, currency: "USD" }
2.times { importer.send(:import_account, account_data) }
stats = @sync.reload.sync_stats
assert_equal 2, stats.dig("zero_runs", "a2"), "should count zero runs"
assert_equal false, stats.dig("inactive", "a2"), "should not be inactive before threshold"
# Multiple imports in the same sync (simulating chunked imports) should only count once
5.times { importer.send(:import_account, account_data) }
importer.send(:import_account, account_data)
stats = @sync.reload.sync_stats
assert_equal true, stats.dig("inactive", "a2"), "should be inactive at threshold"
assert_equal 1, stats.dig("zero_runs", "a2"), "should only count once per sync despite multiple imports"
assert_equal false, stats.dig("inactive", "a2"), "should not be inactive after single count"
end
test "resets zero_runs_count and inactive when activity returns" do
test "resets zero_runs and inactive when activity returns" do
account_data = { id: "a3", name: "Dormant", balance: 0, "available-balance": 0, currency: "USD" }
3.times { importer.send(:import_account, account_data) }
stats = @sync.reload.sync_stats
assert_equal true, stats.dig("inactive", "a3")
importer.send(:import_account, account_data)
# Activity returns: non-zero balance or holdings
stats = @sync.reload.sync_stats
assert_equal 1, stats.dig("zero_runs", "a3")
# Activity returns: non-zero balance
active_data = { id: "a3", name: "Dormant", balance: 10, currency: "USD" }
importer.send(:import_account, active_data)
stats = @sync.reload.sync_stats
assert_equal 0, stats.dig("zero_runs", "a3")
assert_equal false, stats.dig("inactive", "a3")
end
end
# Additional regression: no balances present should not increment zero_runs or mark inactive
class SimplefinItem::ImporterInactiveTest < ActiveSupport::TestCase
test "does not count zero run when both balances are missing and no holdings" do
account_data = { id: "a4", name: "Unknown", currency: "USD" } # no balance keys, no holdings
@@ -59,4 +55,64 @@ class SimplefinItem::ImporterInactiveTest < ActiveSupport::TestCase
assert_equal 0, stats.dig("zero_runs", "a4").to_i
assert_equal false, stats.dig("inactive", "a4")
end
test "skips zero balance detection for credit cards" do
# Create a SimplefinAccount linked to a CreditCard account
sfa = SimplefinAccount.create!(
simplefin_item: @item,
name: "Paid Off Card",
account_id: "cc1",
account_type: "credit",
currency: "USD",
current_balance: 0
)
credit_card = CreditCard.create!
account = @family.accounts.create!(
name: "Paid Off Card",
balance: 0,
currency: "USD",
accountable: credit_card,
simplefin_account_id: sfa.id
)
account_data = { id: "cc1", name: "Paid Off Card", balance: 0, "available-balance": 0, currency: "USD" }
# Even with zero balance and no holdings, credit cards should not trigger the counter
importer.send(:import_account, account_data)
stats = @sync.reload.sync_stats
assert_nil stats.dig("zero_runs", "cc1"), "should not count zero runs for credit cards"
assert_equal false, stats.dig("inactive", "cc1")
end
test "skips zero balance detection for loans" do
# Create a SimplefinAccount linked to a Loan account
sfa = SimplefinAccount.create!(
simplefin_item: @item,
name: "Paid Off Loan",
account_id: "loan1",
account_type: "loan",
currency: "USD",
current_balance: 0
)
loan = Loan.create!
account = @family.accounts.create!(
name: "Paid Off Loan",
balance: 0,
currency: "USD",
accountable: loan,
simplefin_account_id: sfa.id
)
account_data = { id: "loan1", name: "Paid Off Loan", balance: 0, "available-balance": 0, currency: "USD" }
# Even with zero balance and no holdings, loans should not trigger the counter
importer.send(:import_account, account_data)
stats = @sync.reload.sync_stats
assert_nil stats.dig("zero_runs", "loan1"), "should not count zero runs for loans"
assert_equal false, stats.dig("inactive", "loan1")
end
end

View File

@@ -0,0 +1,174 @@
require "test_helper"
class SimplefinItem::ImporterOrphanPruneTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@item = SimplefinItem.create!(family: @family, name: "SF Conn", access_url: "https://example.com/access")
@sync = Sync.create!(syncable: @item)
end
test "prunes orphaned SimplefinAccount records when upstream account_ids change" do
# Create an existing SimplefinAccount with an OLD account_id (simulating a previously synced account)
old_sfa = SimplefinAccount.create!(
simplefin_item: @item,
account_id: "ACT-old-id-12345",
name: "Business",
currency: "USD",
current_balance: 100,
account_type: "checking"
)
# Stub provider to return accounts with NEW account_ids (simulating re-added institution)
mock_provider = mock()
mock_provider.expects(:get_accounts).at_least_once.returns({
accounts: [
{ id: "ACT-new-id-67890", name: "Business", balance: "288.41", currency: "USD", type: "checking" }
]
})
importer = SimplefinItem::Importer.new(@item, simplefin_provider: mock_provider, sync: @sync)
importer.send(:perform_account_discovery)
# The old SimplefinAccount should be pruned
assert_nil SimplefinAccount.find_by(id: old_sfa.id), "old SimplefinAccount with stale account_id should be deleted"
# A new SimplefinAccount should exist with the new account_id
new_sfa = @item.simplefin_accounts.find_by(account_id: "ACT-new-id-67890")
assert_not_nil new_sfa, "new SimplefinAccount should be created"
assert_equal "Business", new_sfa.name
# Stats should reflect the pruning
stats = @sync.reload.sync_stats
assert_equal 1, stats["accounts_pruned"], "should track pruned accounts"
end
test "does not prune SimplefinAccount that is linked to an Account via legacy FK" do
# Create a SimplefinAccount with an old account_id
old_sfa = SimplefinAccount.create!(
simplefin_item: @item,
account_id: "ACT-old-id-12345",
name: "Business",
currency: "USD",
current_balance: 100,
account_type: "checking"
)
# Link it to an Account via legacy FK
account = Account.create!(
family: @family,
name: "Business Checking",
currency: "USD",
balance: 100,
accountable: Depository.create!(subtype: :checking),
simplefin_account_id: old_sfa.id
)
# Stub provider to return accounts with NEW account_ids
mock_provider = mock()
mock_provider.expects(:get_accounts).at_least_once.returns({
accounts: [
{ id: "ACT-new-id-67890", name: "Business", balance: "288.41", currency: "USD", type: "checking" }
]
})
importer = SimplefinItem::Importer.new(@item, simplefin_provider: mock_provider, sync: @sync)
importer.send(:perform_account_discovery)
# The old SimplefinAccount should NOT be pruned because it's linked
assert_not_nil SimplefinAccount.find_by(id: old_sfa.id), "linked SimplefinAccount should not be deleted"
# New SimplefinAccount should also exist
new_sfa = @item.simplefin_accounts.find_by(account_id: "ACT-new-id-67890")
assert_not_nil new_sfa, "new SimplefinAccount should be created"
# Stats should not show any pruning
stats = @sync.reload.sync_stats
assert_nil stats["accounts_pruned"], "should not prune linked accounts"
end
test "does not prune SimplefinAccount that is linked via AccountProvider" do
# Create a SimplefinAccount with an old account_id
old_sfa = SimplefinAccount.create!(
simplefin_item: @item,
account_id: "ACT-old-id-12345",
name: "Business",
currency: "USD",
current_balance: 100,
account_type: "checking"
)
# Create an Account and link via AccountProvider (new system)
account = Account.create!(
family: @family,
name: "Business Checking",
currency: "USD",
balance: 100,
accountable: Depository.create!(subtype: :checking)
)
AccountProvider.create!(account: account, provider: old_sfa)
# Stub provider to return accounts with NEW account_ids
mock_provider = mock()
mock_provider.expects(:get_accounts).at_least_once.returns({
accounts: [
{ id: "ACT-new-id-67890", name: "Business", balance: "288.41", currency: "USD", type: "checking" }
]
})
importer = SimplefinItem::Importer.new(@item, simplefin_provider: mock_provider, sync: @sync)
importer.send(:perform_account_discovery)
# The old SimplefinAccount should NOT be pruned because it's linked via AccountProvider
assert_not_nil SimplefinAccount.find_by(id: old_sfa.id), "linked SimplefinAccount should not be deleted"
# Stats should not show any pruning
stats = @sync.reload.sync_stats
assert_nil stats["accounts_pruned"], "should not prune linked accounts"
end
test "prunes multiple orphaned SimplefinAccounts when institution re-added with all new IDs" do
# Create two old SimplefinAccounts (simulating two accounts from before re-add)
old_sfa1 = SimplefinAccount.create!(
simplefin_item: @item,
account_id: "ACT-old-business",
name: "Business",
currency: "USD",
current_balance: 28.41,
account_type: "checking"
)
old_sfa2 = SimplefinAccount.create!(
simplefin_item: @item,
account_id: "ACT-old-personal",
name: "Personal",
currency: "USD",
current_balance: 308.43,
account_type: "checking"
)
# Stub provider to return accounts with entirely NEW account_ids
mock_provider = mock()
mock_provider.expects(:get_accounts).at_least_once.returns({
accounts: [
{ id: "ACT-new-business", name: "Business", balance: "288.41", currency: "USD", type: "checking" },
{ id: "ACT-new-personal", name: "Personal", balance: "22.43", currency: "USD", type: "checking" }
]
})
importer = SimplefinItem::Importer.new(@item, simplefin_provider: mock_provider, sync: @sync)
importer.send(:perform_account_discovery)
# Both old SimplefinAccounts should be pruned
assert_nil SimplefinAccount.find_by(id: old_sfa1.id), "old Business SimplefinAccount should be deleted"
assert_nil SimplefinAccount.find_by(id: old_sfa2.id), "old Personal SimplefinAccount should be deleted"
# New SimplefinAccounts should exist
assert_equal 2, @item.simplefin_accounts.reload.count, "should have exactly 2 SimplefinAccounts"
assert_not_nil @item.simplefin_accounts.find_by(account_id: "ACT-new-business")
assert_not_nil @item.simplefin_accounts.find_by(account_id: "ACT-new-personal")
# Stats should reflect both pruned
stats = @sync.reload.sync_stats
assert_equal 2, stats["accounts_pruned"], "should track both pruned accounts"
end
end

View File

@@ -62,4 +62,54 @@ class TradeImportTest < ActiveSupport::TestCase
assert_equal "complete", @import.status
end
test "auto-categorizes buy trades and leaves sell trades uncategorized" do
aapl = securities(:aapl)
aapl_resolver = mock
aapl_resolver.stubs(:resolve).returns(aapl)
Security::Resolver.stubs(:new).returns(aapl_resolver)
# Create the investment category if it doesn't exist
account = accounts(:depository)
family = account.family
savings_category = family.categories.find_or_create_by!(name: "Savings & Investments") do |c|
c.color = "#059669"
c.classification = "expense"
c.lucide_icon = "piggy-bank"
end
import = <<~CSV
date,ticker,qty,price,currency,name
01/01/2024,AAPL,10,150.00,USD,Apple Buy
01/02/2024,AAPL,-5,160.00,USD,Apple Sell
CSV
@import.update!(
account: account,
raw_file_str: import,
date_col_label: "date",
ticker_col_label: "ticker",
qty_col_label: "qty",
price_col_label: "price",
date_format: "%m/%d/%Y",
signage_convention: "inflows_positive"
)
@import.generate_rows_from_csv
@import.reload
assert_difference -> { Trade.count } => 2 do
@import.publish
end
# Find trades created by this import
imported_trades = Trade.joins(:entry).where(entries: { import_id: @import.id })
buy_trade = imported_trades.find { |t| t.qty.positive? }
sell_trade = imported_trades.find { |t| t.qty.negative? }
assert_not_nil buy_trade, "Buy trade should have been created"
assert_not_nil sell_trade, "Sell trade should have been created"
assert_equal savings_category, buy_trade.category, "Buy trade should be auto-categorized as Savings & Investments"
assert_nil sell_trade.category, "Sell trade should not be auto-categorized"
end
end

View File

@@ -14,6 +14,7 @@ class TransactionImportTest < ActiveSupport::TestCase
test "configured? if uploaded and rows are generated" do
@import.expects(:uploaded?).returns(true).once
@import.expects(:rows_count).returns(1).once
assert @import.configured?
end

View File

@@ -276,4 +276,59 @@ class UserTest < ActiveSupport::TestCase
assert_not @user.dashboard_section_collapsed?("net_worth_chart"),
"Should return false when section key is missing from collapsed_sections"
end
# SSO-only user security tests
test "sso_only? returns true for user with OIDC identity and no password" do
sso_user = users(:sso_only)
assert_nil sso_user.password_digest
assert sso_user.oidc_identities.exists?
assert sso_user.sso_only?
end
test "sso_only? returns false for user with password and OIDC identity" do
# family_admin has both password and OIDC identity
assert @user.password_digest.present?
assert @user.oidc_identities.exists?
assert_not @user.sso_only?
end
test "sso_only? returns false for user with password but no OIDC identity" do
user_without_oidc = users(:empty)
assert user_without_oidc.password_digest.present?
assert_not user_without_oidc.oidc_identities.exists?
assert_not user_without_oidc.sso_only?
end
test "has_local_password? returns true when password_digest is present" do
assert @user.has_local_password?
end
test "has_local_password? returns false when password_digest is nil" do
sso_user = users(:sso_only)
assert_not sso_user.has_local_password?
end
test "user can be created without password when skip_password_validation is true" do
user = User.new(
email: "newssuser@example.com",
first_name: "New",
last_name: "SSO User",
skip_password_validation: true,
family: families(:empty)
)
assert user.valid?, user.errors.full_messages.to_sentence
assert user.save
assert_nil user.password_digest
end
test "user requires password on create when skip_password_validation is false" do
user = User.new(
email: "needspassword@example.com",
first_name: "Needs",
last_name: "Password",
family: families(:empty)
)
assert_not user.valid?
assert_includes user.errors[:password], "can't be blank"
end
end

View File

@@ -0,0 +1,36 @@
require "application_system_test_case"
class DragAndDropImportTest < ApplicationSystemTestCase
setup do
sign_in users(:family_admin)
end
test "upload csv via hidden input on transactions index" do
visit transactions_path
assert_selector "#transactions[data-controller*='drag-and-drop-import']"
# We can't easily simulate a true native drag-and-drop in headless chrome via Capybara without complex JS.
# However, we can verify that the hidden form exists and works when a file is "dropped" (input populated).
# The Stimulus controller's job is just to transfer the dropped file to the input and submit.
file_path = file_fixture("imports/transactions.csv")
# Manually make form and input visible
execute_script("
var form = document.querySelector('form[action=\"#{imports_path}\"]');
form.classList.remove('hidden');
var input = document.querySelector('input[name=\"import[csv_file]\"]');
input.classList.remove('hidden');
input.style.display = 'block';
")
attach_file "import[csv_file]", file_path
# Submit the form manually since we bypassed the 'drop' event listener which triggers submit
find("form[action='#{imports_path}']").evaluate_script("this.requestSubmit()")
assert_text "CSV uploaded successfully"
assert_text "Configure your import"
end
end