mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 14:51:15 +00:00
752 lines
30 KiB
Ruby
752 lines
30 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 "device OAuth copy resolves directly in German" do
|
|
flatten_keys = lambda do |translations, prefix = nil|
|
|
translations.flat_map do |key, value|
|
|
path = [ prefix, key ].compact.join(".")
|
|
value.is_a?(Hash) ? flatten_keys.call(value, path) : path
|
|
end
|
|
end
|
|
|
|
scoped_keys = %w[
|
|
snaptrade_items.oauth_device_flow
|
|
snaptrade_items.start_oauth_device_flow
|
|
snaptrade_items.complete_oauth_device_flow
|
|
].flat_map do |scope|
|
|
translations = I18n.t(scope, locale: :en, fallback: false, default: nil)
|
|
assert_kind_of Hash, translations, "#{scope} must remain a translation subtree"
|
|
|
|
flatten_keys.call(translations).map { |key| "#{scope}.#{key}" }
|
|
end
|
|
|
|
scoped_keys.concat %w[
|
|
providers.snaptrade.oauth_setup_step_1_html
|
|
providers.snaptrade.oauth_setup_step_2_html
|
|
providers.snaptrade.oauth_setup_step_3
|
|
providers.snaptrade.oauth_status_ready_device
|
|
providers.snaptrade.oauth_device_button
|
|
]
|
|
|
|
scoped_keys.each do |key|
|
|
assert I18n.t(key, locale: :de, fallback: false, default: nil).present?, "de is missing #{key}"
|
|
end
|
|
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
|