feat(api): expose provider connection health (#1636)

* feat(api): expose provider connection health

* fix(api): harden provider health review paths

* fix(api): refine provider health responses

* test(api): align provider health docs key scope

* fix(api): clarify provider connection status

* fix(api): batch provider connection sync status

* fix(api): polish provider connection status review feedback

* fix(api): correct provider connection summaries
This commit is contained in:
ghost
2026-05-06 16:42:32 -06:00
committed by GitHub
parent d1081547ec
commit 45c5284148
10 changed files with 887 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
# frozen_string_literal: true
class Api::V1::ProviderConnectionsController < Api::V1::BaseController
before_action :ensure_read_scope
def index
@provider_connections = ProviderConnectionStatus.for_family(Current.family)
render :index
rescue StandardError => e
Rails.logger.error "ProviderConnectionsController#index error: #{e.message}"
e.backtrace&.each { |line| Rails.logger.error line }
render_json({
error: "internal_server_error",
message: "An unexpected error occurred"
}, status: :internal_server_error)
end
private
def ensure_read_scope
authorize_scope!(:read)
end
end

View File

@@ -0,0 +1,248 @@
# frozen_string_literal: true
class ProviderConnectionStatus
PROVIDERS = [
{ key: "plaid", type: "PlaidItem", association: :plaid_items, accounts: :plaid_accounts },
{ key: "simplefin", type: "SimplefinItem", association: :simplefin_items, accounts: :simplefin_accounts },
{ key: "lunchflow", type: "LunchflowItem", association: :lunchflow_items, accounts: :lunchflow_accounts },
{ key: "enable_banking", type: "EnableBankingItem", association: :enable_banking_items, accounts: :enable_banking_accounts },
{ key: "coinbase", type: "CoinbaseItem", association: :coinbase_items, accounts: :coinbase_accounts },
{ key: "binance", type: "BinanceItem", association: :binance_items, accounts: :binance_accounts },
{ key: "coinstats", type: "CoinstatsItem", association: :coinstats_items, accounts: :coinstats_accounts },
{ key: "snaptrade", type: "SnaptradeItem", association: :snaptrade_items, accounts: :snaptrade_accounts, linked_accounts: :linked_accounts },
{ key: "mercury", type: "MercuryItem", association: :mercury_items, accounts: :mercury_accounts },
{ key: "sophtron", type: "SophtronItem", association: :sophtron_items, accounts: :sophtron_accounts },
{ key: "indexa_capital", type: "IndexaCapitalItem", association: :indexa_capital_items, accounts: :indexa_capital_accounts }
].freeze
class << self
def for_family(family)
PROVIDERS.flat_map do |provider|
relation = family.public_send(provider[:association])
items = relation.includes(association_includes_for(relation, provider)).ordered.to_a
sync_contexts = sync_contexts_for(provider[:type], items)
items.map do |item|
new(provider, item, sync_contexts.fetch(item.id, {})).to_h
end
end
end
private
def association_includes_for(relation, provider)
includes = [ { provider[:accounts] => :account_provider } ]
includes << provider[:linked_accounts] if provider[:linked_accounts]
includes << :accounts if relation.klass.reflect_on_association(:accounts)
includes
end
def sync_contexts_for(provider_type, items)
item_ids = items.map(&:id)
return {} if item_ids.empty?
latest_syncs = latest_syncs_for(provider_type, item_ids)
latest_completed_syncs = latest_syncs_for(provider_type, item_ids, scope: Sync.completed)
syncing_item_ids = Sync.visible
.where(syncable_type: provider_type, syncable_id: item_ids)
.distinct
.pluck(:syncable_id)
item_ids.index_with do |item_id|
{
latest_sync: latest_syncs[item_id],
latest_completed_sync: latest_completed_syncs[item_id],
syncing: syncing_item_ids.include?(item_id)
}
end
end
def latest_syncs_for(provider_type, item_ids, scope: Sync.all)
ranked_syncs = scope.where(syncable_type: provider_type, syncable_id: item_ids)
.select(
"syncs.*, " \
"ROW_NUMBER() OVER (PARTITION BY syncable_id ORDER BY created_at DESC, id DESC) AS sync_rank"
)
Sync.from(ranked_syncs, :syncs).where("sync_rank = 1").index_by(&:syncable_id)
end
end
def initialize(provider, item, sync_context = {})
@provider = provider
@item = item
@sync_context = sync_context
end
def to_h
{
id: item.id,
provider: provider[:key],
provider_type: provider[:type],
name: item_value(:name, provider[:key].humanize),
status: item_value(:status),
requires_update: item_boolean(:requires_update?),
credentials_configured: credentials_configured?,
scheduled_for_deletion: item_boolean(:scheduled_for_deletion?),
pending_account_setup: pending_account_setup?,
institution: institution_payload,
accounts: accounts_payload,
sync: sync_payload,
created_at: item.created_at,
updated_at: item.updated_at
}
end
private
attr_reader :provider, :item, :sync_context
def credentials_configured?
item_boolean(:credentials_configured?)
end
def pending_account_setup?
item_boolean(:pending_account_setup?)
end
def institution_payload
{
name: item_value(:institution_display_name, item_value(:name, provider[:key].humanize)),
domain: item_value(:institution_domain),
url: item_value(:institution_url)
}
end
def accounts_payload
@accounts_payload ||= begin
total = provider_account_count
linked = linked_account_count
{
total_count: total,
linked_count: linked,
unlinked_count: [ total - linked, 0 ].max
}
end
end
def provider_account_count
records = provider_account_records
return records.size if records
return item.total_accounts_count if item.respond_to?(:total_accounts_count)
0
end
def linked_account_count
records = provider_account_records
return records.count { |provider_account| linked_provider_account?(provider_account) } if records
return item.linked_accounts_count if item.respond_to?(:linked_accounts_count)
if provider[:linked_accounts] && item.respond_to?(provider[:linked_accounts])
return item.public_send(provider[:linked_accounts]).size
end
return item.accounts.size if item.respond_to?(:accounts)
0
end
def provider_account_records
return unless item.respond_to?(provider[:accounts])
@provider_account_records ||= item.public_send(provider[:accounts]).to_a
end
def linked_provider_account?(provider_account)
return false unless provider_account.respond_to?(:account_provider)
association = provider_account.association(:account_provider)
association.loaded? ? association.target.present? : provider_account.account_provider.present?
end
def sync_payload
{
syncing: syncing?,
status_summary: sync_status_summary,
last_synced_at: latest_completed_sync&.completed_at,
latest: latest_sync_payload(latest_sync)
}
end
def sync_status_summary
stats = latest_completed_sync_stats
counts = accounts_payload
total = stats.fetch("total_accounts", counts[:total_count]).to_i
linked = stats.fetch("linked_accounts", counts[:linked_count]).to_i
unlinked = stats.fetch("unlinked_accounts", [ total - linked, 0 ].max).to_i
if total.zero?
"No accounts found"
elsif unlinked.zero?
"#{linked} #{'account'.pluralize(linked)} synced"
else
"#{linked} synced, #{unlinked} need setup"
end
end
def syncing?
return sync_context[:syncing] if sync_context.key?(:syncing)
item_boolean(:syncing?)
end
def latest_sync
sync_context[:latest_sync]
end
def latest_completed_sync
sync_context[:latest_completed_sync]
end
def latest_completed_sync_stats
stats = latest_completed_sync&.sync_stats
return stats.stringify_keys if stats.is_a?(Hash)
return {} unless stats.is_a?(String)
parsed = JSON.parse(stats)
parsed.is_a?(Hash) ? parsed.stringify_keys : {}
rescue JSON::ParserError
{}
end
def latest_sync_payload(sync)
return unless sync
{
id: sync.id,
status: sync.status,
created_at: sync.created_at,
syncing_at: sync.syncing_at,
completed_at: sync.completed_at,
failed_at: sync.failed_at,
error: sync_error_payload(sync)
}
end
def sync_error_payload(sync)
return unless sync.failed? || sync.stale?
# Provider health treats stale connections as actionable even when the
# generic sync API suppresses stale-without-error payloads.
{
present: true,
message: sync.stale? ? "Sync became stale before completion" : "Sync failed"
}
end
def item_boolean(method_name)
item_value(method_name, false) == true
end
def item_value(method_name, default = nil)
return default unless item.respond_to?(method_name)
item.public_send(method_name)
end
end

View File

@@ -0,0 +1,17 @@
# frozen_string_literal: true
json.extract! provider_connection,
:id,
:provider,
:provider_type,
:name,
:status,
:requires_update,
:credentials_configured,
:scheduled_for_deletion,
:pending_account_setup,
:institution,
:accounts,
:sync,
:created_at,
:updated_at

View File

@@ -0,0 +1,5 @@
# frozen_string_literal: true
json.data do
json.array! @provider_connections, partial: "api/v1/provider_connections/provider_connection", as: :provider_connection
end

View File

@@ -454,6 +454,7 @@ Rails.application.routes.draw do
resources :syncs, only: [ :index, :show ] do
get :latest, on: :collection
end
resources :provider_connections, only: [ :index ]
resources :chats, only: [ :index, :show, :create, :update, :destroy ] do
resources :messages, only: [ :create ] do

View File

@@ -1755,6 +1755,164 @@ components:
properties:
data:
"$ref": "#/components/schemas/ImportDetail"
ProviderConnectionInstitution:
type: object
required:
- name
properties:
name:
type: string
nullable: true
domain:
type: string
nullable: true
url:
type: string
nullable: true
ProviderConnectionAccounts:
type: object
required:
- total_count
- linked_count
- unlinked_count
properties:
total_count:
type: integer
minimum: 0
linked_count:
type: integer
minimum: 0
unlinked_count:
type: integer
minimum: 0
ProviderConnectionSyncLatest:
type: object
required:
- id
- status
- created_at
properties:
id:
type: string
format: uuid
status:
type: string
created_at:
type: string
format: date-time
syncing_at:
type: string
format: date-time
nullable: true
completed_at:
type: string
format: date-time
nullable: true
failed_at:
type: string
format: date-time
nullable: true
error:
type: object
nullable: true
description: Sanitized latest sync error summary. Null when the latest sync
is not failed or stale.
required:
- present
properties:
present:
type: boolean
description: Always true when this object is present.
message:
type: string
nullable: true
description: Stable sanitized error category message; raw provider error
text is never exposed.
ProviderConnectionSync:
type: object
required:
- syncing
properties:
syncing:
type: boolean
status_summary:
type: string
nullable: true
last_synced_at:
type: string
format: date-time
nullable: true
latest:
allOf:
- "$ref": "#/components/schemas/ProviderConnectionSyncLatest"
nullable: true
ProviderConnection:
type: object
required:
- id
- provider
- provider_type
- name
- status
- requires_update
- credentials_configured
- scheduled_for_deletion
- pending_account_setup
- institution
- accounts
- sync
- created_at
- updated_at
properties:
id:
type: string
format: uuid
provider:
type: string
provider_type:
type: string
name:
type: string
status:
type: string
nullable: true
requires_update:
type: boolean
nullable: true
description: False when the provider item does not expose this status.
credentials_configured:
type: boolean
nullable: true
description: False when credential readiness is unknown.
scheduled_for_deletion:
type: boolean
nullable: true
description: False when the provider item does not expose this status.
pending_account_setup:
type: boolean
nullable: true
description: False when account setup state is unknown.
institution:
"$ref": "#/components/schemas/ProviderConnectionInstitution"
accounts:
"$ref": "#/components/schemas/ProviderConnectionAccounts"
sync:
"$ref": "#/components/schemas/ProviderConnectionSync"
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
ProviderConnectionCollection:
type: object
required:
- data
properties:
data:
type: array
items:
"$ref": "#/components/schemas/ProviderConnection"
ImportRowMapping:
type: object
required:
@@ -4448,6 +4606,35 @@ paths:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
"/api/v1/provider_connections":
get:
summary: Lists provider connection status summaries
description: List safe provider connection status metadata for the authenticated
user's family without exposing credentials, raw provider payloads, or raw
sync errors.
tags:
- Provider Connections
security:
- apiKeyAuth: []
responses:
'200':
description: provider connection status summaries listed
content:
application/json:
schema:
"$ref": "#/components/schemas/ProviderConnectionCollection"
'401':
description: unauthorized
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
'403':
description: insufficient scope
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
"/api/v1/recurring_transactions":
get:
summary: List recurring transactions

View File

@@ -0,0 +1,54 @@
# frozen_string_literal: true
require "swagger_helper"
RSpec.describe "Api::V1::ProviderConnections", type: :request do
let(:user) { users(:family_admin) }
let(:api_key) do
key = ApiKey.generate_secure_key
ApiKey.create!(
user: user,
name: "API Docs Key",
key: key,
scopes: %w[read_write],
source: "web"
)
end
let(:api_key_without_read_scope) do
key = ApiKey.generate_secure_key
ApiKey.new(
user: user,
name: "API Docs Write Key",
key: key,
scopes: %w[write],
source: "web"
).tap { |api_key| api_key.save!(validate: false) }
end
let(:'X-Api-Key') { api_key.plain_key }
path "/api/v1/provider_connections" do
get "Lists provider connection status summaries" do
description "List safe provider connection status metadata for the authenticated user's family without exposing credentials, raw provider payloads, or raw sync errors."
tags "Provider Connections"
security [ { apiKeyAuth: [] } ]
produces "application/json"
response "200", "provider connection status summaries listed" do
schema "$ref" => "#/components/schemas/ProviderConnectionCollection"
run_test!
end
response "401", "unauthorized" do
let(:'X-Api-Key') { nil }
schema "$ref" => "#/components/schemas/ErrorResponse"
run_test!
end
response "403", "insufficient scope" do
let(:'X-Api-Key') { api_key_without_read_scope.plain_key }
schema "$ref" => "#/components/schemas/ErrorResponse"
run_test!
end
end
end
end

View File

@@ -954,6 +954,89 @@ RSpec.configure do |config|
data: { '$ref' => '#/components/schemas/ImportDetail' }
}
},
ProviderConnectionInstitution: {
type: :object,
required: %w[name],
properties: {
name: { type: :string, nullable: true },
domain: { type: :string, nullable: true },
url: { type: :string, nullable: true }
}
},
ProviderConnectionAccounts: {
type: :object,
required: %w[total_count linked_count unlinked_count],
properties: {
total_count: { type: :integer, minimum: 0 },
linked_count: { type: :integer, minimum: 0 },
unlinked_count: { type: :integer, minimum: 0 }
}
},
ProviderConnectionSyncLatest: {
type: :object,
required: %w[id status created_at],
properties: {
id: { type: :string, format: :uuid },
status: { type: :string },
created_at: { type: :string, format: :'date-time' },
syncing_at: { type: :string, format: :'date-time', nullable: true },
completed_at: { type: :string, format: :'date-time', nullable: true },
failed_at: { type: :string, format: :'date-time', nullable: true },
error: {
type: :object,
nullable: true,
description: "Sanitized latest sync error summary. Null when the latest sync is not failed or stale.",
required: %w[present],
properties: {
present: { type: :boolean, description: "Always true when this object is present." },
message: { type: :string, nullable: true, description: "Stable sanitized error category message; raw provider error text is never exposed." }
}
}
}
},
ProviderConnectionSync: {
type: :object,
required: %w[syncing],
properties: {
syncing: { type: :boolean },
status_summary: { type: :string, nullable: true },
last_synced_at: { type: :string, format: :'date-time', nullable: true },
latest: {
allOf: [ { '$ref' => '#/components/schemas/ProviderConnectionSyncLatest' } ],
nullable: true
}
}
},
ProviderConnection: {
type: :object,
required: %w[id provider provider_type name status requires_update credentials_configured scheduled_for_deletion pending_account_setup institution accounts sync created_at updated_at],
properties: {
id: { type: :string, format: :uuid },
provider: { type: :string },
provider_type: { type: :string },
name: { type: :string },
status: { type: :string, nullable: true },
requires_update: { type: :boolean, nullable: true, description: "False when the provider item does not expose this status." },
credentials_configured: { type: :boolean, nullable: true, description: "False when credential readiness is unknown." },
scheduled_for_deletion: { type: :boolean, nullable: true, description: "False when the provider item does not expose this status." },
pending_account_setup: { type: :boolean, nullable: true, description: "False when account setup state is unknown." },
institution: { '$ref' => '#/components/schemas/ProviderConnectionInstitution' },
accounts: { '$ref' => '#/components/schemas/ProviderConnectionAccounts' },
sync: { '$ref' => '#/components/schemas/ProviderConnectionSync' },
created_at: { type: :string, format: :'date-time' },
updated_at: { type: :string, format: :'date-time' }
}
},
ProviderConnectionCollection: {
type: :object,
required: %w[data],
properties: {
data: {
type: :array,
items: { '$ref' => '#/components/schemas/ProviderConnection' }
}
}
},
ImportRowMapping: {
type: :object,
required: %w[key type value create_when_empty creatable mappable],

View File

@@ -0,0 +1,187 @@
# frozen_string_literal: true
require "test_helper"
class Api::V1::ProviderConnectionsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@family = @user.family
@mercury_item = mercury_items(:one)
@user.api_keys.active.destroy_all
@api_key = ApiKey.create!(
user: @user,
name: "Test Read Key",
scopes: [ "read" ],
display_key: "test_read_#{SecureRandom.hex(8)}",
source: "web"
)
@read_write_key = ApiKey.create!(
user: @user,
name: "Test Read-Write Key",
scopes: [ "read_write" ],
display_key: "test_rw_#{SecureRandom.hex(8)}",
source: "mobile"
)
redis = Redis.new
redis.del("api_rate_limit:#{@api_key.id}")
redis.del("api_rate_limit:#{@read_write_key.id}")
end
test "lists provider connection status for current family" do
failed_sync = @mercury_item.syncs.create!(
status: "failed",
failed_at: Time.current,
error: "secret token failed"
)
get api_v1_provider_connections_url, headers: api_headers(@api_key)
assert_response :success
json_response = JSON.parse(response.body)
mercury_connection = json_response["data"].detect do |connection|
connection["id"] == @mercury_item.id && connection["provider"] == "mercury"
end
assert_not_nil mercury_connection
assert_equal "mercury", mercury_connection["provider"]
assert_equal "MercuryItem", mercury_connection["provider_type"]
assert_equal @mercury_item.name, mercury_connection["name"]
assert_equal @mercury_item.status, mercury_connection["status"]
assert_includes [ true, false ], mercury_connection["requires_update"]
assert_equal true, mercury_connection["credentials_configured"]
assert_includes [ true, false ], mercury_connection["scheduled_for_deletion"]
assert_includes [ true, false ], mercury_connection["pending_account_setup"]
assert_equal @mercury_item.mercury_accounts.count, mercury_connection["accounts"]["total_count"]
assert_equal failed_sync.id, mercury_connection["sync"]["latest"]["id"]
assert_equal true, mercury_connection["sync"]["latest"]["error"]["present"]
assert_equal "Sync failed", mercury_connection["sync"]["latest"]["error"]["message"]
end
test "reports failed sync errors as present without exposing raw messages" do
failed_sync = @mercury_item.syncs.create!(
status: "failed",
failed_at: Time.current,
error: nil
)
get api_v1_provider_connections_url, headers: api_headers(@api_key)
assert_response :success
mercury_connection = JSON.parse(response.body)["data"].detect do |connection|
connection["id"] == @mercury_item.id && connection["provider"] == "mercury"
end
assert_equal failed_sync.id, mercury_connection["sync"]["latest"]["id"]
assert_equal true, mercury_connection["sync"]["latest"]["error"]["present"]
assert_equal "Sync failed", mercury_connection["sync"]["latest"]["error"]["message"]
end
test "reports stale sync errors as present" do
stale_sync = @mercury_item.syncs.create!(
status: "stale",
syncing_at: 2.days.ago
)
get api_v1_provider_connections_url, headers: api_headers(@api_key)
assert_response :success
mercury_connection = JSON.parse(response.body)["data"].detect do |connection|
connection["id"] == @mercury_item.id && connection["provider"] == "mercury"
end
assert_equal stale_sync.id, mercury_connection["sync"]["latest"]["id"]
assert_equal true, mercury_connection["sync"]["latest"]["error"]["present"]
assert_equal "Sync became stale before completion", mercury_connection["sync"]["latest"]["error"]["message"]
end
test "does not expose provider secrets or raw sync errors" do
@mercury_item.syncs.create!(
status: "failed",
failed_at: Time.current,
error: "raw provider token secret"
)
get api_v1_provider_connections_url, headers: api_headers(@api_key)
assert_response :success
refute_includes response.body, @mercury_item.token
refute_includes response.body, "raw provider token secret"
end
test "fails closed when credential readiness is unknown" do
get api_v1_provider_connections_url, headers: api_headers(@api_key)
assert_response :success
plaid_connection = JSON.parse(response.body)["data"].detect do |connection|
connection["provider"] == "plaid"
end
assert_not_nil plaid_connection
assert_includes [ true, false ], plaid_connection["requires_update"]
assert_equal false, plaid_connection["credentials_configured"]
assert_includes [ true, false ], plaid_connection["scheduled_for_deletion"]
assert_includes [ true, false ], plaid_connection["pending_account_setup"]
end
test "excludes another family's provider connections" do
other_item = snaptrade_items(:pending_registration_item)
get api_v1_provider_connections_url, headers: api_headers(@api_key)
assert_response :success
ids = JSON.parse(response.body)["data"].map { |connection| connection["id"] }
assert_not_includes ids, other_item.id
end
test "read_write key can list provider connection status" do
get api_v1_provider_connections_url, headers: api_headers(@read_write_key)
assert_response :success
end
test "returns an empty list when no provider connections exist" do
ProviderConnectionStatus.stub(:for_family, []) do
get api_v1_provider_connections_url, headers: api_headers(@api_key)
end
assert_response :success
assert_equal [], JSON.parse(response.body)["data"]
end
test "requires authentication" do
get api_v1_provider_connections_url
assert_response :unauthorized
end
test "rejects api keys without read scope" do
write_only_key = ApiKey.new(
user: @user,
name: "Test Write Key",
scopes: [ "write" ],
display_key: "test_write_#{SecureRandom.hex(8)}",
source: "monitoring"
).tap { |api_key| api_key.save!(validate: false) }
get api_v1_provider_connections_url, headers: api_headers(write_only_key)
assert_response :forbidden
end
test "does not leak internal provider status errors" do
ProviderConnectionStatus.stub(:for_family, ->(_family) { raise StandardError, "secret provider failure" }) do
get api_v1_provider_connections_url, headers: api_headers(@api_key)
end
assert_response :internal_server_error
assert_equal "internal_server_error", JSON.parse(response.body)["error"]
refute_includes response.body, "secret provider failure"
end
private
def api_headers(api_key)
{ "X-Api-Key" => api_key.plain_key }
end
end

View File

@@ -0,0 +1,81 @@
# frozen_string_literal: true
require "test_helper"
class ProviderConnectionStatusTest < ActiveSupport::TestCase
test "provider registry covers syncable family provider item associations" do
expected_registry = Family.reflect_on_all_associations(:has_many).filter_map do |association|
next unless association.name.to_s.end_with?("_items")
next unless association.klass.included_modules.include?(Syncable)
{ association: association.name, type: association.klass.name }
end
registered_registry = ProviderConnectionStatus::PROVIDERS.map do |provider|
{ association: provider[:association], type: provider[:type] }
end
assert_equal expected_registry.sort_by { |entry| entry[:association].to_s },
registered_registry.sort_by { |entry| entry[:association].to_s }
end
test "status summary is computed without calling provider item summary" do
provider = ProviderConnectionStatus::PROVIDERS.find { |entry| entry[:association] == :mercury_items }
item = mercury_items(:one)
completed_sync = item.syncs.create!(
status: "completed",
created_at: 1.hour.ago,
completed_at: 1.hour.ago,
sync_stats: {
total_accounts: 2,
linked_accounts: 1,
unlinked_accounts: 1
}
)
failed_sync = item.syncs.create!(
status: "failed",
created_at: Time.current,
failed_at: Time.current,
sync_stats: {
total_accounts: 9,
linked_accounts: 9,
unlinked_accounts: 0
}
)
item.expects(:sync_status_summary).never
status = ProviderConnectionStatus.new(
provider,
item,
latest_sync: failed_sync,
latest_completed_sync: completed_sync,
syncing: false
).to_h
assert_equal "1 synced, 1 need setup", status.dig(:sync, :status_summary)
assert_equal failed_sync.id, status.dig(:sync, :latest, :id)
end
test "account counts use provider account links instead of linked account fallback" do
provider = ProviderConnectionStatus::PROVIDERS.find { |entry| entry[:association] == :mercury_items }
item = mercury_items(:one)
linked_provider_account = item.mercury_accounts.create!(
account_id: "merc_acc_savings_2",
name: "Mercury Savings",
currency: "USD"
)
AccountProvider.create!(
account: accounts(:other_asset),
provider: linked_provider_account
)
item.association(:mercury_accounts).reset
status = ProviderConnectionStatus.new(provider, item, syncing: false).to_h
assert_equal 2, status.dig(:accounts, :total_count)
assert_equal 1, status.dig(:accounts, :linked_count)
assert_equal 1, status.dig(:accounts, :unlinked_count)
end
end