Files
sure/test/controllers/snaptrade_items_controller_test.rb
T
Brandon bf5ceff269 Fix flaky sign_out teardown in six test suites (#3208)
Six suites (passkey, MFA, SnapTrade, categorize, onboarding and the Active
Storage authorization integration tests) share a sign_out helper that deletes
the user's sessions through the controller, one HTTP request per session,
iterating in unspecified order. The moment the loop deletes the session the
test itself is signed in with, every later request in the loop is
unauthenticated and silently deletes nothing, so whichever sessions happen to
sort after it survive. The sessions fixture belongs to the same user these
suites use, so a surviving fixture row then fails every assertion that expects
the user to have no sessions.

Row order usually favors the fixture, which is why the suites usually pass.
Under parallel CI they fail a few times a week, always in this file family,
always with the fixture session as the leftover. Forcing newest-first order
reproduces it deterministically on current main: ten of the fifteen passkey
tests fail.

Teardown hygiene is not the behavior under test, so the helpers now destroy
the sessions directly, which no order can break. All six suites run green
three times in a row.
2026-08-27 07:30:35 +02:00

720 lines
28 KiB
Ruby

require "test_helper"
class SnaptradeItemsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
@snaptrade_item = snaptrade_items(:configured_item)
end
def sign_out
# Deleting sessions through the controller de-authenticates the request the
# moment our own session dies, so every later delete in the loop is a
# silent no-op and whichever sessions sort after it survive. The order is
# unspecified, which made every suite that signs out this way flaky.
# Teardown hygiene is not the behavior under test, so destroy directly.
@user.sessions.destroy_all
end
# A deployment with a confidential OAuth client: both the browser redirect and
# the device code are available.
def with_oauth_app(&block)
with_snaptrade_oauth("client-id", "client-secret", &block)
end
# A deployment that only registered a public client: device code only.
def with_device_flow_only(&block)
with_snaptrade_oauth("client-id", nil, &block)
end
# Restores whatever was configured rather than clearing, so a test env that
# does set SnapTrade credentials doesn't leak nils into later tests.
def with_snaptrade_oauth(client_id, client_secret)
original_id = Rails.configuration.x.snaptrade.oauth_client_id
original_secret = Rails.configuration.x.snaptrade.oauth_client_secret
Rails.configuration.x.snaptrade.oauth_client_id = client_id
Rails.configuration.x.snaptrade.oauth_client_secret = client_secret
yield
ensure
Rails.configuration.x.snaptrade.oauth_client_id = original_id
Rails.configuration.x.snaptrade.oauth_client_secret = original_secret
end
DEVICE_AUTHORIZATION = {
"device_code" => "dev-c0de",
"user_code" => "WXYZ-1234",
"verification_uri" => "https://app.snaptrade.com/device",
"verification_uri_complete" => "https://app.snaptrade.com/device?code=WXYZ-1234",
"expires_in" => 600,
"interval" => 5
}.freeze
# The device code is held in the session, so completing a flow means starting
# one first -- the same two requests a user makes.
def start_device_flow(item, **params)
Provider::Snaptrade.stubs(:start_device_authorization).returns(DEVICE_AUTHORIZATION.dup)
post start_oauth_device_flow_snaptrade_items_url, params: { item_id: item.id }.merge(params)
end
test "connect redirects to portal when successful" do
portal_url = "https://app.snaptrade.com/portal/test123"
SnaptradeItem.any_instance.stubs(:connection_portal_url).returns(portal_url)
get connect_snaptrade_item_url(@snaptrade_item)
assert_redirected_to portal_url
end
test "connect handles decryption error gracefully" do
SnaptradeItem.any_instance
.stubs(:connection_portal_url)
.raises(ActiveRecord::Encryption::Errors::Decryption.new("cannot decrypt"))
get connect_snaptrade_item_url(@snaptrade_item)
assert_redirected_to settings_providers_path
assert_match(/Unable to read SnapTrade credentials/, flash[:alert])
end
test "connect handles general error gracefully" do
SnaptradeItem.any_instance
.stubs(:connection_portal_url)
.raises(StandardError.new("something broke"))
get connect_snaptrade_item_url(@snaptrade_item)
assert_redirected_to settings_providers_path
assert_match(/Failed to connect/, flash[:alert])
end
test "oauth_authorize stores state and verifier in session and redirects to SnapTrade" do
Rails.configuration.x.snaptrade.oauth_client_id = "client-id"
Rails.configuration.x.snaptrade.oauth_client_secret = "client-secret"
get oauth_authorize_snaptrade_items_url
assert_response :redirect
redirect = URI.parse(response.location)
assert_equal "dashboard.snaptrade.com", redirect.host
params = Rack::Utils.parse_query(redirect.query)
oauth_session = session[:snaptrade_oauth]
assert_equal oauth_session["state"], params["state"]
assert_equal "S256", params["code_challenge_method"]
assert oauth_session["code_verifier"].present?
assert oauth_session["item_id"].present?
ensure
Rails.configuration.x.snaptrade.oauth_client_id = nil
Rails.configuration.x.snaptrade.oauth_client_secret = nil
end
test "oauth_authorize redirects to settings when OAuth is not configured" do
Rails.configuration.x.snaptrade.oauth_client_id = nil
Rails.configuration.x.snaptrade.oauth_client_secret = nil
get oauth_authorize_snaptrade_items_url
assert_redirected_to settings_providers_path
end
test "oauth_authorize refuses when only the public client id is configured" do
with_device_flow_only do
get oauth_authorize_snaptrade_items_url
assert_redirected_to settings_providers_path
assert_match(/device code/, flash[:alert])
end
end
test "oauth_device_authorize offers to start authorization" do
Provider::Snaptrade.expects(:start_device_authorization).never
with_device_flow_only do
get oauth_device_authorize_snaptrade_items_url
assert_response :success
assert_select "form[action='#{start_oauth_device_flow_snaptrade_items_path}']"
end
end
test "oauth_device_authorize explains what to configure when the client id is missing" do
with_snaptrade_oauth(nil, nil) do
get oauth_device_authorize_snaptrade_items_url
assert_response :unprocessable_entity
assert_match(/SNAPTRADE_OAUTH_CLIENT_ID/, response.body)
end
end
test "start_oauth_device_flow shows the code the user confirms on SnapTrade" do
with_device_flow_only do
Provider::Snaptrade.expects(:start_device_authorization).with(scope: "read").returns({
"device_code" => "dev-c0de",
"user_code" => "WXYZ-1234",
"verification_uri" => "https://app.snaptrade.com/device",
"verification_uri_complete" => "https://app.snaptrade.com/device?code=WXYZ-1234",
"expires_in" => 600,
"interval" => 5
})
post start_oauth_device_flow_snaptrade_items_url, params: { item_id: @snaptrade_item.id }
assert_response :success
assert_match "WXYZ-1234", response.body
# The user code is for the user to read; the device code redeems into
# tokens, so it stays in the session and never reaches the page.
assert_select "input[name='device_code']", count: 0
assert_no_match "dev-c0de", response.body
end
end
test "complete_oauth_device_flow stores tokens and queues a sync" do
item = snaptrade_items(:unauthorized_item)
sign_out
sign_in @user = users(:empty)
with_device_flow_only do
Provider::Snaptrade.expects(:poll_device_token).with(device_code: "dev-c0de").returns({
"access_token" => "device-at", "refresh_token" => "device-rt", "expires_in" => 900,
"token_type" => "Bearer", "scope" => "read"
})
start_device_flow(item)
assert_difference "Sync.count", 1 do
post complete_oauth_device_flow_snaptrade_item_url(item)
end
assert_redirected_to settings_providers_path
assert_equal "device-at", item.reload.oauth_access_token
assert item.good?
end
end
test "complete_oauth_device_flow leaves the drawer instead of navigating inside it" do
with_device_flow_only do
Provider::Snaptrade.stubs(:poll_device_token).returns({
"access_token" => "device-at", "refresh_token" => "device-rt", "expires_in" => 900
})
start_device_flow(@snaptrade_item)
post complete_oauth_device_flow_snaptrade_item_url(@snaptrade_item),
headers: { "Turbo-Frame" => "drawer" }
# A plain redirect would be followed as a frame navigation and swap in the
# layout's empty drawer frame, closing the dialog and going nowhere.
assert_response :success
assert_match "turbo-stream", response.body
assert_match "redirect", response.body
assert_match settings_providers_path, response.body
assert_equal I18n.t("snaptrade_items.complete_oauth_device_flow.success"), flash[:notice]
end
end
test "complete_oauth_device_flow resumes account setup when asked to" do
with_device_flow_only do
Provider::Snaptrade.stubs(:poll_device_token).returns({
"access_token" => "device-at", "refresh_token" => "device-rt", "expires_in" => 900
})
start_device_flow(@snaptrade_item, return_to: "setup_accounts", accountable_type: "Investment")
# Where to return to was recorded when the flow started, so completion
# needs nothing from the form to find its way back.
post complete_oauth_device_flow_snaptrade_item_url(@snaptrade_item)
assert_redirected_to setup_accounts_snaptrade_item_path(@snaptrade_item, accountable_type: "Investment")
end
end
test "complete_oauth_device_flow keeps the code when the user has not confirmed it yet" do
item = snaptrade_items(:unauthorized_item)
sign_out
sign_in @user = users(:empty)
with_device_flow_only do
Provider::Snaptrade.expects(:poll_device_token).raises(
Provider::Snaptrade::AuthenticationError.new("SnapTrade OAuth token request failed: authorization_pending", oauth_error: "authorization_pending")
)
start_device_flow(item)
post complete_oauth_device_flow_snaptrade_item_url(item)
assert_response :unprocessable_entity
# Still redeemable, so the page comes back with the same code rather than
# sending the user back to the start.
assert_match "WXYZ-1234", response.body
assert_select "form[action='#{complete_oauth_device_flow_snaptrade_item_path(item)}']"
assert_nil item.reload.oauth_access_token
end
end
test "complete_oauth_device_flow drops a code the server says is spent" do
with_device_flow_only do
Provider::Snaptrade.expects(:poll_device_token).raises(
Provider::Snaptrade::AuthenticationError.new("SnapTrade OAuth token request failed: expired_token", oauth_error: "expired_token")
)
start_device_flow(@snaptrade_item)
post complete_oauth_device_flow_snaptrade_item_url(@snaptrade_item)
assert_response :unprocessable_entity
# Nothing left to retry with, so the page offers a fresh start instead of
# a button that can only fail again.
assert_no_match "WXYZ-1234", response.body
assert_select "form[action='#{start_oauth_device_flow_snaptrade_items_path}']"
end
end
test "complete_oauth_device_flow requires a flow started in this session" do
with_device_flow_only do
Provider::Snaptrade.expects(:poll_device_token).never
post complete_oauth_device_flow_snaptrade_item_url(@snaptrade_item)
assert_response :unprocessable_entity
end
end
# The device code is a bearer credential that says nothing about who asked for
# it. If the request body could supply one, anyone who came by a code -- from a
# log, a shared screen -- could redeem someone else's pending authorization
# into an item of their own. Only the session that started the flow can finish
# it.
test "complete_oauth_device_flow ignores a device code supplied as a parameter" do
item = snaptrade_items(:unauthorized_item)
sign_out
sign_in @user = users(:empty)
with_device_flow_only do
Provider::Snaptrade.expects(:poll_device_token).never
post complete_oauth_device_flow_snaptrade_item_url(item), params: { device_code: "someone-elses-code" }
assert_response :unprocessable_entity
assert_nil item.reload.oauth_access_token
end
end
test "complete_oauth_device_flow will not redeem a code into a different item" do
other_item = @user.family.snaptrade_items.create!(name: "Another SnapTrade")
with_device_flow_only do
Provider::Snaptrade.expects(:poll_device_token).never
start_device_flow(@snaptrade_item)
post complete_oauth_device_flow_snaptrade_item_url(other_item)
assert_response :unprocessable_entity
assert_nil other_item.reload.oauth_access_token
end
end
# The device code is the whole capability check on complete_oauth_device_flow;
# nothing binds it to the family that requested it, the way state does in the
# redirect flow. So it must not reach the request log.
test "device flow codes are filtered from logs" do
parameter_filter = ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters)
filtered_params = parameter_filter.filter(
device_code: "dev-c0de",
user_code: "WXYZ-1234",
verification_uri_complete: "https://app.snaptrade.com/device?code=WXYZ-1234"
)
assert_equal "[FILTERED]", filtered_params[:device_code]
assert_equal "[FILTERED]", filtered_params[:user_code]
assert_equal "[FILTERED]", filtered_params[:verification_uri_complete]
end
test "oauth_callback rejects state mismatch without exchanging the code" do
Provider::Snaptrade.expects(:exchange_code).never
Rails.configuration.x.snaptrade.oauth_client_id = "client-id"
Rails.configuration.x.snaptrade.oauth_client_secret = "client-secret"
get oauth_authorize_snaptrade_items_url
get oauth_callback_snaptrade_items_url(code: "c0de", state: "wrong-state")
assert_redirected_to settings_providers_path
assert flash[:alert].present?
ensure
Rails.configuration.x.snaptrade.oauth_client_id = nil
Rails.configuration.x.snaptrade.oauth_client_secret = nil
end
test "oauth_callback handles access_denied" do
get oauth_callback_snaptrade_items_url(error: "access_denied", state: "whatever")
assert_redirected_to settings_providers_path
assert flash[:alert].present?
end
test "oauth_callback fails when code param is missing" do
Provider::Snaptrade.expects(:exchange_code).never
Rails.configuration.x.snaptrade.oauth_client_id = "client-id"
Rails.configuration.x.snaptrade.oauth_client_secret = "client-secret"
get oauth_authorize_snaptrade_items_url
oauth_session = session[:snaptrade_oauth]
get oauth_callback_snaptrade_items_url(state: oauth_session["state"])
assert_redirected_to settings_providers_path
assert_equal "Unable to complete SnapTrade authorization. Please try again.", flash[:alert]
assert_nil session[:snaptrade_oauth]
ensure
Rails.configuration.x.snaptrade.oauth_client_id = nil
Rails.configuration.x.snaptrade.oauth_client_secret = nil
end
test "oauth_callback handles exchange failures without applying tokens" do
Rails.configuration.x.snaptrade.oauth_client_id = "client-id"
Rails.configuration.x.snaptrade.oauth_client_secret = "client-secret"
get oauth_authorize_snaptrade_items_url
oauth_session = session[:snaptrade_oauth]
item = SnaptradeItem.find(oauth_session["item_id"])
original_token = item.oauth_access_token
Provider::Snaptrade.expects(:exchange_code)
.with(code: "c0de", redirect_uri: oauth_callback_snaptrade_items_url, code_verifier: oauth_session["code_verifier"])
.raises(Provider::Snaptrade::Error.new("upstream exchange failed"))
get oauth_callback_snaptrade_items_url(code: "c0de", state: oauth_session["state"])
assert_redirected_to settings_providers_path
assert_equal "Unable to complete SnapTrade authorization. Please try again.", flash[:alert]
assert_nil session[:snaptrade_oauth]
assert_equal original_token, item.reload.oauth_access_token
ensure
Rails.configuration.x.snaptrade.oauth_client_id = nil
Rails.configuration.x.snaptrade.oauth_client_secret = nil
end
test "oauth_callback exchanges code, stores tokens, queues sync, and resumes setup" do
Rails.configuration.x.snaptrade.oauth_client_id = "client-id"
Rails.configuration.x.snaptrade.oauth_client_secret = "client-secret"
get oauth_authorize_snaptrade_items_url(return_to: "setup_accounts")
oauth_session = session[:snaptrade_oauth]
item = SnaptradeItem.find(oauth_session["item_id"])
Provider::Snaptrade.expects(:exchange_code)
.with(code: "c0de", redirect_uri: oauth_callback_snaptrade_items_url, code_verifier: oauth_session["code_verifier"])
.returns({ "access_token" => "at", "refresh_token" => "rt", "expires_in" => 900 })
get oauth_callback_snaptrade_items_url(code: "c0de", state: oauth_session["state"])
assert_redirected_to setup_accounts_snaptrade_item_path(item, accountable_type: nil)
assert_equal "at", item.reload.oauth_access_token
assert_nil session[:snaptrade_oauth]
ensure
Rails.configuration.x.snaptrade.oauth_client_id = nil
Rails.configuration.x.snaptrade.oauth_client_secret = nil
end
test "oauth_callback clears a reconnect warning after successful authorization" do
Rails.configuration.x.snaptrade.oauth_client_id = "client-id"
Rails.configuration.x.snaptrade.oauth_client_secret = "client-secret"
@snaptrade_item.update!(status: :requires_update)
get oauth_authorize_snaptrade_items_url(item_id: @snaptrade_item.id)
oauth_session = session[:snaptrade_oauth]
Provider::Snaptrade.expects(:exchange_code).returns({ "access_token" => "at", "refresh_token" => "rt" })
get oauth_callback_snaptrade_items_url(code: "c0de", state: oauth_session["state"])
assert @snaptrade_item.reload.good?
ensure
Rails.configuration.x.snaptrade.oauth_client_id = nil
Rails.configuration.x.snaptrade.oauth_client_secret = nil
end
test "oauth_callback queues a sync even while activities are fetching" do
Rails.configuration.x.snaptrade.oauth_client_id = "client-id"
Rails.configuration.x.snaptrade.oauth_client_secret = "client-secret"
get oauth_authorize_snaptrade_items_url(item_id: @snaptrade_item.id)
oauth_session = session[:snaptrade_oauth]
Provider::Snaptrade.expects(:exchange_code).returns({ "access_token" => "at", "refresh_token" => "rt" })
active_sync = @snaptrade_item.syncs.create!
active_sync.start!
assert_enqueued_with job: SnaptradeFollowUpSyncJob do
get oauth_callback_snaptrade_items_url(code: "c0de", state: oauth_session["state"])
end
ensure
Rails.configuration.x.snaptrade.oauth_client_id = nil
Rails.configuration.x.snaptrade.oauth_client_secret = nil
end
test "Reconnect starts OAuth authorization instead of adding a brokerage" do
@snaptrade_item.update!(status: :requires_update)
with_oauth_app do
get accounts_url
assert_select "a[href='#{oauth_authorize_snaptrade_items_path(item_id: @snaptrade_item.id)}'][data-turbo-frame='_top']", text: /Reconnect/
assert_select "a[href='#{connect_snaptrade_item_path(@snaptrade_item)}']", text: /Reconnect/, count: 0
end
end
test "Reconnect uses the device flow when the deployment has no confidential client" do
@snaptrade_item.update!(status: :requires_update)
with_device_flow_only do
get accounts_url
assert_select "a[href='#{oauth_device_authorize_snaptrade_items_path(item_id: @snaptrade_item.id)}'][data-turbo-frame='drawer']", text: /Reconnect/
end
end
test "select_accounts redirects unregistered users into connect flow" do
sign_out
sign_in @user = users(:empty)
snaptrade_item = snaptrade_items(:unauthorized_item)
with_oauth_app do
get select_accounts_snaptrade_items_url, params: { accountable_type: "Investment", return_to: "setup_accounts" }
assert_redirected_to oauth_authorize_snaptrade_items_path(
item_id: snaptrade_item.id,
accountable_type: "Investment",
return_to: "setup_accounts"
)
end
end
test "select_accounts sends unregistered users to the device flow without a confidential client" do
sign_out
sign_in @user = users(:empty)
snaptrade_item = snaptrade_items(:unauthorized_item)
with_device_flow_only do
get select_accounts_snaptrade_items_url, params: { accountable_type: "Investment", return_to: "setup_accounts" }
assert_redirected_to oauth_device_authorize_snaptrade_items_path(
item_id: snaptrade_item.id,
accountable_type: "Investment",
return_to: "setup_accounts"
)
end
end
test "select_accounts redirects registered users to setup flow" do
get select_accounts_snaptrade_items_url, params: { accountable_type: "Investment", return_to: "/accounts" }
assert_redirected_to setup_accounts_snaptrade_item_path(@snaptrade_item, accountable_type: "Investment", return_to: "/accounts")
end
test "preload_accounts redirects unregistered users into connect flow" do
sign_out
sign_in @user = users(:empty)
with_oauth_app do
assert_no_difference "Sync.count" do
get preload_accounts_snaptrade_items_url
end
assert_redirected_to oauth_authorize_snaptrade_items_path(item_id: snaptrade_items(:unauthorized_item).id)
end
end
test "preload_accounts redirects registered users to setup flow and queues sync" do
assert_difference "Sync.count", 1 do
get preload_accounts_snaptrade_items_url
end
assert_redirected_to setup_accounts_snaptrade_item_path(@snaptrade_item)
end
test "entry routing prefers a registered active item over a pending one" do
pending_item = @user.family.snaptrade_items.create!(
name: "Pending Registration",
status: :good,
scheduled_for_deletion: false,
pending_account_setup: true
)
get select_accounts_snaptrade_items_url, params: { accountable_type: "Investment", return_to: "/accounts" }
assert_redirected_to setup_accounts_snaptrade_item_path(@snaptrade_item, accountable_type: "Investment", return_to: "/accounts")
assert_difference "Sync.count", 1 do
get preload_accounts_snaptrade_items_url
end
assert_redirected_to setup_accounts_snaptrade_item_path(@snaptrade_item)
assert_not pending_item.oauth_configured?
end
test "setup_accounts shows linkable investment and crypto accounts in dropdown" do
get setup_accounts_snaptrade_item_url(@snaptrade_item)
assert_response :success
# Investment and crypto accounts (no provider) should appear in the link dropdown
assert_match accounts(:investment).name, response.body
assert_match accounts(:crypto).name, response.body
# Depository should NOT appear in the link dropdown (wrong type)
# The depository name may appear elsewhere on the page, so check the select options specifically
refute_match(/option.*#{accounts(:depository).name}/, response.body)
end
test "setup_accounts excludes accounts that already have a provider from dropdown" do
# Link the investment account to a snaptrade_account
AccountProvider.create!(
account: accounts(:investment),
provider: snaptrade_accounts(:fidelity_401k)
)
get setup_accounts_snaptrade_item_url(@snaptrade_item)
assert_response :success
# Investment account is now linked → should NOT appear in link dropdown options
refute_match(/option.*#{accounts(:investment).name}/, response.body)
# Crypto still unlinked → should appear
assert_match accounts(:crypto).name, response.body
end
test "setup_accounts preselects types from SnapTrade account categories" do
account = snaptrade_accounts(:fidelity_401k)
account.update!(raw_payload: { "account_category" => "DEPOSIT" })
get setup_accounts_snaptrade_item_url(@snaptrade_item)
assert_select "input[name='account_types[#{account.id}]'][value='Depository']"
end
test "complete_account_setup uses the selected account type" do
account = snaptrade_accounts(:fidelity_401k)
@snaptrade_item.stubs(:sync_later)
assert_difference "Account.where(accountable_type: 'Depository').count", 1 do
post complete_account_setup_snaptrade_item_url(@snaptrade_item), params: {
account_ids: [ account.id ],
account_types: { account.id => "Depository" }
}
end
assert_equal "Depository", account.reload.current_account.accountable_type
end
test "select_existing_account prefers registered active item over pending one" do
pending_item = @user.family.snaptrade_items.create!(
name: "Pending Registration",
status: :good,
scheduled_for_deletion: false,
pending_account_setup: true
)
pending_item.snaptrade_accounts.create!(
snaptrade_account_id: "pending_snaptrade_account",
name: "Pending Brokerage Account",
brokerage_name: "Pending Broker",
currency: "USD",
current_balance: 0
)
get select_existing_account_snaptrade_items_url, params: { account_id: accounts(:investment).id }
assert_response :success
assert_includes response.body, snaptrade_accounts(:fidelity_401k).name
refute_includes response.body, "Pending Brokerage Account"
end
test "link_existing_account links account to snaptrade_account" do
account = accounts(:investment)
snaptrade_account = snaptrade_accounts(:fidelity_401k)
assert_difference "AccountProvider.count", 1 do
post link_existing_account_snaptrade_items_url, params: {
account_id: account.id,
snaptrade_account_id: snaptrade_account.id,
snaptrade_item_id: @snaptrade_item.id
}
end
assert_redirected_to account_path(account)
assert_match(/Successfully linked/, flash[:notice])
snaptrade_account.reload
assert_equal account, snaptrade_account.current_account
end
test "link_existing_account handles missing account gracefully" do
snaptrade_account = snaptrade_accounts(:fidelity_401k)
assert_no_difference "AccountProvider.count" do
post link_existing_account_snaptrade_items_url, params: {
account_id: "nonexistent",
snaptrade_account_id: snaptrade_account.id,
snaptrade_item_id: @snaptrade_item.id
}
end
assert_redirected_to settings_providers_path
assert_match(/not found/i, flash[:alert])
end
# --- setup_accounts throttle-sync fix ---
#
# The fix on setup_accounts ensures sync_later is only called when there are no
# accounts AND the item has never been synced (last_synced_at.blank?). This
# prevents the infinite-spinner loop where every page load re-triggered a sync
# even after SnapTrade already confirmed 0 linked accounts.
#
# Three view-state branches we need to cover:
# A) No accounts + never synced → trigger sync, render spinner
# B) No accounts + synced once, now idle → skip sync, show "no accounts found"
# C) No accounts + synced once, still syncing → show spinner, do NOT re-queue
test "setup_accounts triggers sync and shows spinner when item has no accounts and has never been synced" do
# Pre-condition: no snaptrade_accounts and no completed syncs (last_synced_at is nil)
@snaptrade_item.snaptrade_accounts.destroy_all
@snaptrade_item.syncs.destroy_all
assert_difference "Sync.count", 1 do
get setup_accounts_snaptrade_item_url(@snaptrade_item)
end
assert_response :success
assert_select "#snaptrade-sync-spinner", count: 1, message: "Expected the spinner to be shown on first visit with no accounts"
assert_select ".no-accounts-found", count: 0, message: "Expected the no-accounts UI to be hidden while syncing"
end
test "setup_accounts shows no-accounts-found state after a completed sync returns zero accounts" do
# Pre-condition: no snaptrade_accounts, but there IS a past completed sync
@snaptrade_item.snaptrade_accounts.destroy_all
@snaptrade_item.syncs.destroy_all
@snaptrade_item.syncs.create!(status: :completed, completed_at: 1.minute.ago)
# Item is not currently syncing → @syncing is false
assert_not @snaptrade_item.reload.syncing?, "Item should not be syncing for this test"
assert_no_difference "Sync.count" do
get setup_accounts_snaptrade_item_url(@snaptrade_item)
end
assert_response :success
assert_select ".no-accounts-found", count: 1, message: "Expected the no-accounts UI to be shown after a completed sync with zero accounts"
assert_select "#snaptrade-sync-spinner", count: 0, message: "Expected the spinner to be hidden when there is no active sync"
assert_select "a[href=?]", connect_snaptrade_item_path(@snaptrade_item, return_to: "setup_accounts", accountable_type: nil), text: /Connect Brokerage/
assert_no_match oauth_authorize_snaptrade_items_path(item_id: @snaptrade_item.id), response.body
end
test "setup_accounts does not re-queue a sync when a sync is already in progress" do
# Pre-condition: no accounts, one past completed sync, + one visible (in-flight) sync
@snaptrade_item.snaptrade_accounts.destroy_all
@snaptrade_item.syncs.destroy_all
@snaptrade_item.syncs.create!(status: :completed, completed_at: 5.minutes.ago)
@snaptrade_item.syncs.create!(status: :pending, created_at: 1.minute.ago) # visible/in-flight
assert @snaptrade_item.reload.syncing?, "Item should be syncing for this test"
assert_no_difference "Sync.count" do
get setup_accounts_snaptrade_item_url(@snaptrade_item)
end
assert_response :success
assert_select "#snaptrade-sync-spinner", count: 1, message: "Expected the spinner to be shown while sync is in progress"
assert_select ".no-accounts-found", count: 0, message: "Expected the no-accounts UI to be hidden while a sync is active"
end
end