mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 03:54:08 +00:00
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:
206
test/controllers/api/v1/imports_controller_test.rb
Normal file
206
test/controllers/api/v1/imports_controller_test.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
178
test/controllers/coinstats_items_controller_test.rb
Normal file
178
test/controllers/coinstats_items_controller_test.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
6
test/fixtures/lunchflow_accounts.yml
vendored
Normal 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
5
test/fixtures/lunchflow_items.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
one:
|
||||
family: dylan_family
|
||||
name: "Test Lunchflow Connection"
|
||||
api_key: "test_api_key_123"
|
||||
status: good
|
||||
11
test/fixtures/oidc_identities.yml
vendored
11
test/fixtures/oidc_identities.yml
vendored
@@ -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 %>
|
||||
|
||||
2
test/fixtures/security/prices.yml
vendored
2
test/fixtures/security/prices.yml
vendored
@@ -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
2
test/fixtures/simplefin_accounts.yml
vendored
Normal 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
2
test/fixtures/simplefin_items.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Empty fixture to ensure the simplefin_items table is truncated during tests.
|
||||
# Tests create SimplefinItem records explicitly in setup.
|
||||
13
test/fixtures/users.yml
vendored
13
test/fixtures/users.yml
vendored
@@ -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
|
||||
41
test/jobs/apply_all_rules_job_test.rb
Normal file
41
test/jobs/apply_all_rules_job_test.rb
Normal 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
|
||||
33
test/jobs/sync_hourly_job_test.rb
Normal file
33
test/jobs/sync_hourly_job_test.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
|
||||
159
test/models/coinstats_account/processor_test.rb
Normal file
159
test/models/coinstats_account/processor_test.rb
Normal 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
|
||||
350
test/models/coinstats_account/transactions/processor_test.rb
Normal file
350
test/models/coinstats_account/transactions/processor_test.rb
Normal 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
|
||||
202
test/models/coinstats_account_test.rb
Normal file
202
test/models/coinstats_account_test.rb
Normal 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
|
||||
267
test/models/coinstats_entry/processor_test.rb
Normal file
267
test/models/coinstats_entry/processor_test.rb
Normal 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
|
||||
480
test/models/coinstats_item/importer_test.rb
Normal file
480
test/models/coinstats_item/importer_test.rb
Normal 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
|
||||
177
test/models/coinstats_item/syncer_test.rb
Normal file
177
test/models/coinstats_item/syncer_test.rb
Normal 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
|
||||
280
test/models/coinstats_item/wallet_linker_test.rb
Normal file
280
test/models/coinstats_item/wallet_linker_test.rb
Normal 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
|
||||
231
test/models/coinstats_item_test.rb
Normal file
231
test/models/coinstats_item_test.rb
Normal 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
|
||||
69
test/models/concerns/currency_normalizable_test.rb
Normal file
69
test/models/concerns/currency_normalizable_test.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
124
test/models/provider/coinstats_adapter_test.rb
Normal file
124
test/models/provider/coinstats_adapter_test.rb
Normal 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
|
||||
164
test/models/provider/coinstats_test.rb
Normal file
164
test/models/provider/coinstats_test.rb
Normal 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
|
||||
144
test/models/provider/simplefin_test.rb
Normal file
144
test/models/provider/simplefin_test.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 cross‑suite 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
174
test/models/simplefin_item/importer_orphan_prune_test.rb
Normal file
174
test/models/simplefin_item/importer_orphan_prune_test.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
36
test/system/drag_and_drop_import_test.rb
Normal file
36
test/system/drag_and_drop_import_test.rb
Normal 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
|
||||
Reference in New Issue
Block a user