fix(mercury): support named multiple API connections (#1627)

* fix(mercury): support named multiple connections

* fix(mercury): address multi-connection review feedback

* fix(mercury): localize connection labels

* fix(mercury): strip API tokens before provider calls

* test(mercury): localize provider config assertions

* fix(mercury): address multi-connection review

* refactor(mercury): simplify connection selection failure
This commit is contained in:
ghost
2026-05-03 02:56:31 -06:00
committed by GitHub
parent e677d382c2
commit 6c84fc760e
13 changed files with 747 additions and 129 deletions

View File

@@ -0,0 +1,268 @@
# frozen_string_literal: true
require "test_helper"
class MercuryItemsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
Rails.cache.clear
SyncJob.stubs(:perform_later)
@family = families(:dylan_family)
@existing_item = mercury_items(:one)
@second_item = MercuryItem.create!(
family: @family,
name: "Business Mercury",
token: "second_mercury_token",
base_url: "https://api.mercury.com/api/v1"
)
end
teardown do
Rails.cache.clear
end
test "create adds a new mercury connection without overwriting existing credentials" do
existing_token = @existing_item.token
assert_difference "MercuryItem.count", 1 do
post mercury_items_url, params: {
mercury_item: {
name: "Joint Mercury",
token: "joint_mercury_token",
base_url: "https://api.mercury.com/api/v1"
}
}
end
assert_redirected_to accounts_path
assert_equal existing_token, @existing_item.reload.token
assert_equal "joint_mercury_token", @family.mercury_items.find_by!(name: "Joint Mercury").token
end
test "update changes only the selected mercury connection" do
existing_token = @existing_item.token
patch mercury_item_url(@second_item), params: {
mercury_item: {
name: "Renamed Business Mercury",
token: "updated_second_token",
base_url: "https://api-sandbox.mercury.com/api/v1"
}
}
assert_redirected_to accounts_path
assert_equal existing_token, @existing_item.reload.token
assert_equal "Renamed Business Mercury", @second_item.reload.name
assert_equal "updated_second_token", @second_item.token
assert_equal "https://api-sandbox.mercury.com/api/v1", @second_item.base_url
end
test "blank token update preserves the selected mercury token" do
original_token = @second_item.token
patch mercury_item_url(@second_item), params: {
mercury_item: {
name: "Renamed Business Mercury",
token: "",
base_url: "https://api.mercury.com/api/v1"
}
}
assert_redirected_to accounts_path
assert_equal "Renamed Business Mercury", @second_item.reload.name
assert_equal original_token, @second_item.token
end
test "update expires selected mercury account cache when credentials change" do
Rails.cache.expects(:delete).with(mercury_cache_key(@existing_item)).never
Rails.cache.expects(:delete).with(mercury_cache_key(@second_item)).once
patch mercury_item_url(@second_item), params: {
mercury_item: {
name: "Renamed Business Mercury",
token: "updated_second_token",
base_url: "https://api-sandbox.mercury.com/api/v1"
}
}
assert_redirected_to accounts_path
end
test "update does not expire selected mercury account cache for name-only changes" do
Rails.cache.expects(:delete).never
patch mercury_item_url(@second_item), params: {
mercury_item: {
name: "Renamed Business Mercury"
}
}
assert_redirected_to accounts_path
assert_equal "Renamed Business Mercury", @second_item.reload.name
end
test "preload accounts uses selected mercury item cache key" do
Rails.cache.expects(:read).with(mercury_cache_key(@second_item)).returns(nil)
Rails.cache.expects(:write).with(mercury_cache_key(@second_item), mercury_accounts_payload, expires_in: 5.minutes)
provider = mock("mercury_provider")
provider.expects(:get_accounts).returns(accounts: mercury_accounts_payload)
Provider::Mercury.expects(:new)
.with(@second_item.token, base_url: @second_item.effective_base_url)
.returns(provider)
get preload_accounts_mercury_items_url, params: { mercury_item_id: @second_item.id }, as: :json
assert_response :success
response = JSON.parse(@response.body)
assert_equal true, response["success"]
assert_equal true, response["has_accounts"]
end
test "select accounts requires an explicit connection when multiple mercury items exist" do
get select_accounts_mercury_items_url, params: { accountable_type: "Depository" }
assert_redirected_to settings_providers_path
assert_equal "Choose a Mercury connection in Provider Settings.", flash[:alert]
end
test "select accounts renders the selected mercury item id" do
Rails.cache.expects(:read).with(mercury_cache_key(@second_item)).returns(nil)
Rails.cache.expects(:write).with(mercury_cache_key(@second_item), mercury_accounts_payload, expires_in: 5.minutes)
provider = mock("mercury_provider")
provider.expects(:get_accounts).returns(accounts: mercury_accounts_payload)
Provider::Mercury.expects(:new)
.with(@second_item.token, base_url: @second_item.effective_base_url)
.returns(provider)
get select_accounts_mercury_items_url, params: {
mercury_item_id: @second_item.id,
accountable_type: "Depository"
}
assert_response :success
assert_includes @response.body, %(name="mercury_item_id")
assert_includes @response.body, %(value="#{@second_item.id}")
end
test "select existing account renders the selected mercury item id" do
account = @family.accounts.create!(
name: "Manual Checking",
balance: 0,
currency: "USD",
accountable: Depository.new
)
Rails.cache.expects(:read).with(mercury_cache_key(@second_item)).returns(nil)
Rails.cache.expects(:write).with(mercury_cache_key(@second_item), mercury_accounts_payload, expires_in: 5.minutes)
provider = mock("mercury_provider")
provider.expects(:get_accounts).returns(accounts: mercury_accounts_payload)
Provider::Mercury.expects(:new)
.with(@second_item.token, base_url: @second_item.effective_base_url)
.returns(provider)
get select_existing_account_mercury_items_url, params: {
mercury_item_id: @second_item.id,
account_id: account.id
}
assert_response :success
assert_includes @response.body, %(name="mercury_item_id")
assert_includes @response.body, %(value="#{@second_item.id}")
end
test "link accounts uses selected mercury item and allows duplicate upstream ids across items" do
@existing_item.mercury_accounts.create!(
account_id: "shared_mercury_account",
name: "Shared Checking",
currency: "USD",
current_balance: 1000
)
provider = mock("mercury_provider")
provider.expects(:get_accounts).returns(accounts: mercury_accounts_payload)
Provider::Mercury.expects(:new)
.with(@second_item.token, base_url: @second_item.effective_base_url)
.returns(provider)
assert_difference -> { @second_item.mercury_accounts.where(account_id: "shared_mercury_account").count }, 1 do
assert_difference "AccountProvider.count", 1 do
post link_accounts_mercury_items_url, params: {
mercury_item_id: @second_item.id,
account_ids: [ "shared_mercury_account" ],
accountable_type: "Depository"
}
end
end
assert_redirected_to accounts_path
assert_equal 1, @existing_item.mercury_accounts.where(account_id: "shared_mercury_account").count
end
test "link accounts does not silently use the first connection when multiple items exist" do
assert_no_difference "MercuryAccount.count" do
assert_no_difference "Account.count" do
post link_accounts_mercury_items_url, params: {
account_ids: [ "shared_mercury_account" ],
accountable_type: "Depository"
}
end
end
assert_redirected_to settings_providers_path
assert_equal "Choose a Mercury connection before linking accounts.", flash[:alert]
end
test "link existing account does not silently use the first connection when multiple items exist" do
account = @family.accounts.create!(
name: "Manual Checking",
balance: 0,
currency: "USD",
accountable: Depository.new
)
assert_no_difference "MercuryAccount.count" do
assert_no_difference "AccountProvider.count" do
post link_existing_account_mercury_items_url, params: {
account_id: account.id,
mercury_account_id: "shared_mercury_account"
}
end
end
assert_redirected_to settings_providers_path
assert_equal "Choose a Mercury connection before linking accounts.", flash[:alert]
end
test "sync only queues a sync for the selected mercury item" do
assert_difference -> { Sync.where(syncable: @second_item).count }, 1 do
assert_no_difference -> { Sync.where(syncable: @existing_item).count } do
post sync_mercury_item_url(@second_item)
end
end
assert_response :redirect
end
private
def mercury_accounts_payload
[
{
id: "shared_mercury_account",
nickname: "Shared Checking",
name: "Shared Checking",
status: "active",
type: "checking",
currentBalance: 1000
}
]
end
def mercury_cache_key(mercury_item)
"mercury_accounts_#{@family.id}_#{mercury_item.id}"
end
end

View File

@@ -44,6 +44,34 @@ class MercuryAccountTest < ActiveSupport::TestCase
end
end
test "same account_id can be linked under different mercury_items in the same family" do
item_a_2 = MercuryItem.create!(
family: @family_a,
name: "Family A Second Mercury",
token: "token_a_2",
base_url: "https://api-sandbox.mercury.com/api/v1",
status: "good"
)
MercuryAccount.create!(
mercury_item: @item_a,
account_id: "shared_merc_acc_1",
name: "Checking",
currency: "USD",
current_balance: 5000
)
assert_difference "MercuryAccount.count", 1 do
MercuryAccount.create!(
mercury_item: item_a_2,
account_id: "shared_merc_acc_1",
name: "Checking",
currency: "USD",
current_balance: 5000
)
end
end
test "same account_id cannot appear twice under the same mercury_item" do
MercuryAccount.create!(
mercury_item: @item_a,

View File

@@ -22,6 +22,11 @@ class MercuryItemTest < ActiveSupport::TestCase
assert_not @mercury_item.credentials_configured?
end
test "credentials_configured returns false when token is whitespace" do
@mercury_item.token = " "
assert_not @mercury_item.credentials_configured?
end
test "effective_base_url returns custom url when set" do
assert_equal "https://api-sandbox.mercury.com/api/v1", @mercury_item.effective_base_url
end
@@ -42,6 +47,42 @@ class MercuryItemTest < ActiveSupport::TestCase
assert_nil @mercury_item.mercury_provider
end
test "family credential check ignores blank and scheduled for deletion items" do
family = families(:empty)
blank_item = MercuryItem.create!(
family: family,
name: "Blank Mercury",
token: "temporary_token",
base_url: "https://api-sandbox.mercury.com/api/v1"
)
blank_item.update_column(:token, "")
whitespace_item = MercuryItem.create!(
family: family,
name: "Whitespace Mercury",
token: "temporary_token",
base_url: "https://api-sandbox.mercury.com/api/v1"
)
whitespace_item.update_column(:token, " ")
deleted_item = MercuryItem.create!(
family: family,
name: "Deleted Mercury",
token: "deleted_token",
base_url: "https://api-sandbox.mercury.com/api/v1",
scheduled_for_deletion: true
)
refute family.has_mercury_credentials?
whitespace_item.update_column(:token, "configured_token")
assert family.has_mercury_credentials?
whitespace_item.update_column(:token, " ")
deleted_item.update!(scheduled_for_deletion: false)
assert family.has_mercury_credentials?
end
test "syncer returns MercuryItem::Syncer instance" do
syncer = @mercury_item.send(:syncer)
assert_instance_of MercuryItem::Syncer, syncer

View File

@@ -1,3 +1,5 @@
require "uri"
require "test_helper"
class Provider::MercuryAdapterTest < ActiveSupport::TestCase
@@ -9,17 +11,59 @@ class Provider::MercuryAdapterTest < ActiveSupport::TestCase
assert_not_includes Provider::MercuryAdapter.supported_account_types, "Investment"
end
test "returns connection configs for any family" do
test "returns fallback connection config when no credentials exist yet" do
# Mercury is a per-family provider - any family can connect
family = families(:dylan_family)
family = families(:empty)
configs = Provider::MercuryAdapter.connection_configs(family: family)
assert_equal 1, configs.length
assert_equal "mercury", configs.first[:key]
assert_equal "Mercury", configs.first[:name]
assert_equal I18n.t("mercury_items.provider_connection.default_name"), configs.first[:name]
assert configs.first[:can_connect]
end
test "returns one connection config per credentialed mercury item" do
family = families(:dylan_family)
first_item = mercury_items(:one)
second_item = MercuryItem.create!(
family: family,
name: "Business Mercury",
token: "second_mercury_token",
base_url: "https://api.mercury.com/api/v1"
)
configs = Provider::MercuryAdapter.connection_configs(family: family)
assert_equal 2, configs.length
assert_equal [ "mercury_#{second_item.id}", "mercury_#{first_item.id}" ], configs.map { |config| config[:key] }
assert_equal [
I18n.t("mercury_items.provider_connection.name", name: second_item.name),
I18n.t("mercury_items.provider_connection.name", name: first_item.name)
], configs.map { |config| config[:name] }
new_account_uri = URI.parse(configs.first[:new_account_path].call("Depository", "/accounts"))
assert_equal "/mercury_items/select_accounts", new_account_uri.path
assert_includes new_account_uri.query, "mercury_item_id=#{second_item.id}"
existing_account_uri = URI.parse(configs.first[:existing_account_path].call(accounts(:depository).id))
assert_equal "/mercury_items/select_existing_account", existing_account_uri.path
assert_includes existing_account_uri.query, "mercury_item_id=#{second_item.id}"
end
test "connection configs ignore items with whitespace-only tokens" do
family = families(:dylan_family)
MercuryItem.create!(
family: family,
name: "Blank Mercury",
token: "temporary_token",
base_url: "https://api.mercury.com/api/v1"
).update_column(:token, " ")
configs = Provider::MercuryAdapter.connection_configs(family: family)
assert_equal [ "mercury_#{mercury_items(:one).id}" ], configs.map { |config| config[:key] }
end
test "build_provider returns nil when family is nil" do
assert_nil Provider::MercuryAdapter.build_provider(family: nil)
end
@@ -35,4 +79,59 @@ class Provider::MercuryAdapterTest < ActiveSupport::TestCase
assert_instance_of Provider::Mercury, provider
end
test "build_provider uses explicit mercury item credentials" do
family = families(:dylan_family)
second_item = MercuryItem.create!(
family: family,
name: "Business Mercury",
token: "second_mercury_token",
base_url: "https://api.mercury.com/api/v1"
)
provider = Provider::MercuryAdapter.build_provider(family: family, mercury_item_id: second_item.id)
assert_instance_of Provider::Mercury, provider
assert_equal "second_mercury_token", provider.token
assert_equal "https://api.mercury.com/api/v1", provider.base_url
end
test "build_provider strips surrounding token whitespace" do
family = families(:dylan_family)
second_item = MercuryItem.create!(
family: family,
name: "Business Mercury",
token: " second_mercury_token \n",
base_url: "https://api.mercury.com/api/v1"
)
provider = Provider::MercuryAdapter.build_provider(family: family, mercury_item_id: second_item.id)
assert_equal "second_mercury_token", provider.token
end
test "build_provider refuses mercury items outside the family" do
family = families(:dylan_family)
other_item = MercuryItem.create!(
family: families(:empty),
name: "Other Mercury",
token: "other_mercury_token",
base_url: "https://api.mercury.com/api/v1"
)
assert_nil Provider::MercuryAdapter.build_provider(family: family, mercury_item_id: other_item.id)
end
test "build_provider refuses explicit mercury item without usable credentials" do
family = families(:dylan_family)
blank_item = MercuryItem.create!(
family: family,
name: "Blank Mercury",
token: "temporary_token",
base_url: "https://api.mercury.com/api/v1"
)
blank_item.update_column(:token, " ")
assert_nil Provider::MercuryAdapter.build_provider(family: family, mercury_item_id: blank_item.id)
end
end