mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 14:51:15 +00:00
* fix(onchain-wallets): call transfers transfers, and drop the address swap Three things reported from real use. **A transfer was announced as a purchase.** A movement was imported with `activity_label: "Buy"/"Sell"` and named "Buy 1.5 FAKE" — but coins arriving at an address were not bought there, and nothing here knows whether they were ever bought at all. The trade shape stays, because it is what carries quantity and cost basis in this ledger, but the label is now "Transfer" and the name is the one the movement already had while it was unpriced. The old wording also made the same event rename itself the day a price turned up for it. Worth pairing with the change to trades/_header.html.erb, which until now read the amount's sign and would still say "Buy" whatever the label. **Changing a tracked address is gone.** It repointed the rows at a new address while keeping their accounts, holdings and history — so trades reconstructed from address A stayed under an account presented as address B. Its own help text said so out loud: "The accounts, holdings and history stay as they are." Removing the address and adding the new one is not just simpler, it is the only one of the two that is honest, because it takes the old history with the old address. **The buttons follow the app's conventions now.** Actions that repeat per row belong in a menu here — accounts/_account.html.erb renders its own that way — not in a row of labelled buttons, which is what a provider panel does when it has a single connection to act on. So the per-address actions are a menu, the per-asset disconnect is icon-only, and the accounts-page card gains the actions menu every other provider card already had. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(onchain-wallets): give a tracked asset its icon on the accounts page Account#logo_url asks its provider adapter for one, and ours answered nothing: there is no institution behind a self-custody wallet, and nothing attaches a file, so every tracked asset showed a blank where every other account shows an icon. Fixing Security#crypto_base_asset covered the holdings list, which reads the security directly — this is the other path, and it went through the adapter. Built from the symbol rather than looked up. The accounts page renders one of these per account, so resolving a Security each would be a query per row, and the symbol is all Brandfetch's crypto endpoint needs. It answers nil without a client id, which is the same nothing the page shows today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(onchain-wallets): follow the moved banner and the menu in the browser The system suite still expected the settings panel to carry the read-only reassurance, and to find "Review tokens" as a visible button. Both moved in the previous commit: the banner into the linking modal, where the question it answers is actually asked, and the per-address actions into a menu, as repeated row actions are rendered everywhere else in this app. Caught by CI rather than by me — the per-branch checks I ran covered `bin/rails test` and not `test:system`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(onchain-wallets): pin the reassurance to the frame it moved into The assertion proved the banner was somewhere on the page, which is exactly what the change does not claim: the point is where it lives. Now it asserts the text is absent from the settings panel and present inside the modal, so the test fails if the banner drifts back or never arrives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(onchain-wallets): rename movements imported before transfers had a name Review on #3153. Wallets synced before this change keep their `Buy`/`Sell` labels and their "Buy 1.5 shares of CRYPTO:BTC" wording, and nothing was rewriting them: `perform_sync` returns early when no address changed on chain, and the repair pass only ever looked at display-only `Transaction` rows. A cold address would have shown the old wording indefinitely — which for a wallet nobody touches is most of them. The repair now relabels this processor's own trades too, scoped to its `external_id` prefix and to `source: SOURCE` so a trade the user entered by hand is never renamed. It runs from `perform_post_sync`, which is the pass that already runs for every linked asset rather than only the changed ones. Idempotent, so a nightly sync does not rewrite the same rows forever. Separately: `Security.brandfetch_crypto_url` interpolated the symbol straight into a URL path, and `Onchain::AssetSymbol.canonical` only upcases and trims. An on-chain token can be called whatever its deployer chose, so a slash pointed the path elsewhere on the CDN and a hash pushed the client id into a fragment Brandfetch never sees. Guarded in the helper rather than at the call site — six callers reach it from four providers. Also from review: the icon test saves and restores `Setting.brand_fetch_client_id` instead of hard-coding nil in its `ensure`, which was erasing whatever the suite had configured. The "one query" claim in a repair test's name was never asserted, and this change adds a second query. Renamed to what it actually checks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * test(onchain): drop the last change_address test with its feature #3182 added a `change_address` test while this branch was removing the feature it exercises. The rebase kept both, leaving a test calling a route this branch deletes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
647 lines
23 KiB
Ruby
647 lines
23 KiB
Ruby
require "test_helper"
|
|
|
|
class AccountsControllerTest < ActionDispatch::IntegrationTest
|
|
include ActionView::RecordIdentifier
|
|
include OnchainTestHelper
|
|
|
|
setup do
|
|
sign_in @user = users(:family_admin)
|
|
@account = accounts(:depository)
|
|
end
|
|
|
|
test "should get index" do
|
|
get accounts_url
|
|
assert_response :success
|
|
assert_select "p.ml-auto.privacy-sensitive"
|
|
end
|
|
|
|
test "index renders kraken items" do
|
|
kraken_item = kraken_items(:one)
|
|
get accounts_url
|
|
assert_response :success
|
|
assert_select "##{dom_id(kraken_item)}"
|
|
end
|
|
|
|
test "index renders on-chain wallet items" do
|
|
register_fake_chain!
|
|
item = create_onchain_wallet_item(family: @user.family)
|
|
onchain_account = create_onchain_wallet_account(item: item)
|
|
onchain_account.ensure_account_provider!(accounts(:investment))
|
|
|
|
get accounts_url
|
|
|
|
assert_response :success
|
|
# Without this the wallet is invisible here: its accounts carry a provider
|
|
# link, so Account.manual excludes them, and no provider section claimed them.
|
|
assert_select "##{dom_id(item, :accounts_index)}"
|
|
# The card carries the same actions menu every other provider card does.
|
|
assert_select "form[action=?]", sync_onchain_wallet_item_path(item)
|
|
assert_select "form[action=?]", onchain_wallet_item_path(item)
|
|
ensure
|
|
unregister_fake_chain!
|
|
end
|
|
|
|
test "index does not leak wallet accounts a member was never given" do
|
|
register_fake_chain!
|
|
admin = users(:family_admin)
|
|
member = users(:family_member)
|
|
item = create_onchain_wallet_item(family: admin.family)
|
|
|
|
shared = accounts(:investment)
|
|
unshared = accounts(:credit_card)
|
|
[ shared, unshared ].each_with_index do |account, index|
|
|
account.update!(owner: admin)
|
|
account.account_shares.destroy_all
|
|
row = create_onchain_wallet_account(
|
|
item: item,
|
|
address: "#{OnchainTestHelper::FAKE_ADDRESS}#{index}"
|
|
)
|
|
row.ensure_account_provider!(account)
|
|
end
|
|
shared.account_shares.create!(user: member, permission: "read_only")
|
|
|
|
sign_in member
|
|
get accounts_url
|
|
|
|
assert_response :success
|
|
assert_select "##{dom_id(item, :accounts_index)}"
|
|
# The item is surfaced as soon as ONE of its accounts is accessible, so
|
|
# rendering them all would hand a partially-authorised member the names and
|
|
# balances of accounts nobody shared with them.
|
|
assert_includes response.body, shared.name
|
|
assert_not_includes response.body, unshared.name
|
|
ensure
|
|
unregister_fake_chain!
|
|
end
|
|
|
|
test "should get show" do
|
|
get account_url(@account)
|
|
assert_response :success
|
|
end
|
|
|
|
test "show avoids N+1 transfer queries across paginated entries" do
|
|
queries = capture_sql_queries { get account_url(@account) }
|
|
assert_response :success
|
|
|
|
# Per-row transfer lookups (N+1 pattern) hit transfers with a single id
|
|
# Preloading batches them into IN(...) — assert no single-id lookups remain
|
|
per_row_transfer = queries.count { |q|
|
|
q.match?(/FROM "transfers".*WHERE.*"(inflow|outflow)_transaction_id"/) &&
|
|
!q.include?(" IN (")
|
|
}
|
|
assert_equal 0, per_row_transfer, "N+1 per-row transfer queries detected (#{per_row_transfer})"
|
|
end
|
|
|
|
test "show avoids N+1 split-parent queries across paginated entries" do
|
|
queries = capture_sql_queries { get account_url(@account) }
|
|
assert_response :success
|
|
|
|
# Per-row child-entry existence checks (N+1) hit entries with a single parent_entry_id
|
|
# @split_parent_entry_ids preloads this in one batch IN query
|
|
per_row_split = queries.count { |q|
|
|
q.match?(/FROM "entries".*WHERE.*"parent_entry_id"/) && !q.include?(" IN (")
|
|
}
|
|
assert_equal 0, per_row_split, "N+1 per-row split-parent queries detected (#{per_row_split})"
|
|
end
|
|
|
|
test "show lazily loads statement tab data unless statements tab is active" do
|
|
AccountStatement::Coverage.expects(:for_year).never
|
|
AccountStatement.expects(:reconciliation_statuses_for).never
|
|
|
|
get account_url(@account)
|
|
|
|
assert_response :success
|
|
assert_select "select[name='statement_year']", count: 0
|
|
statements_path = account_path(@account, tab: "statements")
|
|
assert_select "turbo-frame[src='#{statements_path}']"
|
|
end
|
|
|
|
test "statements tab links escape turbo frame for full-page navigation" do
|
|
# Upload a statement to ensure table rows render
|
|
statement = AccountStatement.create_from_upload!(
|
|
family: @account.family,
|
|
file: uploaded_file(
|
|
filename: "test.pdf",
|
|
content_type: "application/pdf",
|
|
content: "%PDF-1.4 test content"
|
|
),
|
|
account: @account
|
|
)
|
|
|
|
|
|
get account_url(@account, tab: "statements")
|
|
|
|
assert_response :success
|
|
|
|
# Inbox link escapes frame
|
|
assert_select "a[href='#{account_statements_path}'][data-turbo-frame='_top']"
|
|
|
|
# Statement filename link escapes frame
|
|
assert_select "a[data-turbo-frame='_top']", text: statement.filename
|
|
|
|
# Eye/view icon escapes frame and opens in new tab
|
|
assert_select "a[target='_blank'][data-turbo-frame='_top'][aria-label='#{I18n.t("account_statements.table.view")}']"
|
|
|
|
# Edit icon escapes frame
|
|
assert_select "a[href='#{account_statement_path(statement)}'][data-turbo-frame='_top'][aria-label='#{I18n.t("account_statements.table.edit")}']"
|
|
|
|
# Unlink button escapes frame
|
|
assert_select "form[action='#{unlink_account_statement_path(statement)}'][data-turbo-frame='_top'] button"
|
|
end
|
|
|
|
test "statements tab shows coverage and upload for statement managers with account write access" do
|
|
get account_url(@account, tab: "statements")
|
|
|
|
assert_response :success
|
|
assert_select "input[type=file][accept='.pdf,.csv,.xlsx']"
|
|
assert_select "select[name='statement_year']"
|
|
assert_select "p", text: I18n.l(Date.current.prev_month.beginning_of_month, format: "%b %Y")
|
|
end
|
|
|
|
test "statements tab lazy frame returns matching frame content" do
|
|
frame_id = dom_id(@account, :statements_tab)
|
|
|
|
get account_url(@account, tab: "statements"), headers: { "Turbo-Frame" => frame_id }
|
|
|
|
assert_response :success
|
|
assert_select "turbo-frame##{frame_id}", count: 1
|
|
assert_select "select[name='statement_year']"
|
|
assert_select "turbo-frame##{dom_id(@account, :container)}", count: 0
|
|
end
|
|
|
|
test "statements tab filters historical coverage by year" do
|
|
account = Account.create!(
|
|
family: @user.family,
|
|
owner: @user,
|
|
name: "Historical Checking",
|
|
balance: 0,
|
|
currency: "USD",
|
|
accountable: Depository.new
|
|
)
|
|
statement = AccountStatement.create_from_upload!(
|
|
family: @user.family,
|
|
account: account,
|
|
file: uploaded_file(filename: "historical.csv", content_type: "text/csv")
|
|
)
|
|
statement.update!(period_start_on: Date.new(2024, 2, 1), period_end_on: Date.new(2024, 2, 29))
|
|
|
|
travel_to Date.new(2026, 5, 6) do
|
|
get account_url(account, tab: "statements")
|
|
|
|
assert_response :success
|
|
assert_select "select[name='statement_year'] option[selected='selected']", text: "2026"
|
|
assert_select "p", text: "May 2026"
|
|
assert_select "p", text: "Not expected"
|
|
|
|
get account_url(account, tab: "statements", statement_year: 2024)
|
|
|
|
assert_response :success
|
|
assert_select "select[name='statement_year'] option[selected='selected']", text: "2024"
|
|
assert_select "p", text: "Jan 2024"
|
|
assert_select "p", text: "Feb 2024"
|
|
assert_select "p", text: "Covered"
|
|
assert_select "p", text: "Missing"
|
|
assert_select "p", text: "Not expected"
|
|
end
|
|
end
|
|
|
|
test "statements tab hides upload for read only account access" do
|
|
sign_in users(:family_member)
|
|
|
|
get account_url(accounts(:credit_card), tab: "statements")
|
|
|
|
assert_response :success
|
|
assert_select "input[type=file]", count: 0
|
|
end
|
|
|
|
test "account activity marks trade amounts as privacy-sensitive" do
|
|
trade_entry = entries(:trade)
|
|
expected_amount = ApplicationController.helpers.format_money(-trade_entry.amount_money)
|
|
|
|
get account_url(accounts(:investment))
|
|
|
|
assert_response :success
|
|
assert_select "turbo-frame##{dom_id(trade_entry)} p.privacy-sensitive", text: expected_amount, count: 1
|
|
end
|
|
|
|
test "account activity keeps excluded entries visible so they can be restored" do
|
|
trade_entry = entries(:trade)
|
|
trade_entry.update!(excluded: true)
|
|
|
|
get account_url(accounts(:investment))
|
|
|
|
assert_response :success
|
|
assert_select "turbo-frame##{dom_id(trade_entry)}"
|
|
end
|
|
|
|
test "renders investment account with gains chart view" do
|
|
get account_url(accounts(:investment), chart_view: "gains")
|
|
|
|
assert_response :success
|
|
assert_select "option[value=gains][selected]"
|
|
assert_select "p", text: I18n.t("UI.account.chart.title.total_gains")
|
|
end
|
|
|
|
test "remembers selected per_page across account navigation" do
|
|
other_account = accounts(:credit_card)
|
|
|
|
get account_url(@account, per_page: 50)
|
|
assert_response :success
|
|
assert_select "select[name='per_page'] option[value='50'][selected]"
|
|
|
|
get account_url(other_account)
|
|
assert_response :success
|
|
assert_select "select[name='per_page'] option[value='50'][selected]"
|
|
end
|
|
|
|
test "shares remembered per_page with the global transactions page" do
|
|
get transactions_url(per_page: 100)
|
|
assert_response :success
|
|
|
|
get account_url(@account)
|
|
assert_response :success
|
|
assert_select "select[name='per_page'] option[value='100'][selected]"
|
|
end
|
|
|
|
test "shares account per_page preference with the global transactions page" do
|
|
get account_url(@account, per_page: 50)
|
|
assert_response :success
|
|
|
|
get transactions_url
|
|
assert_response :redirect
|
|
follow_redirect!
|
|
assert_response :success
|
|
assert_select "select[name='per_page'] option[value='50'][selected]"
|
|
end
|
|
|
|
test "falls back to default per_page when nothing was stored yet" do
|
|
get account_url(@account)
|
|
assert_response :success
|
|
assert_select "select[name='per_page'] option[value='10'][selected]"
|
|
end
|
|
|
|
test "activity pagination keeps activity tab when loaded from holdings tab" do
|
|
investment = accounts(:investment)
|
|
|
|
11.times do |i|
|
|
Entry.create!(
|
|
account: investment,
|
|
name: "Test investment activity #{i}",
|
|
date: Date.current - i.days,
|
|
amount: 10 + i,
|
|
currency: investment.currency,
|
|
entryable: Transaction.new
|
|
)
|
|
end
|
|
|
|
get account_url(investment, tab: "holdings")
|
|
|
|
assert_response :success
|
|
assert_select "a[href*='page=2'][href*='tab=activity']"
|
|
assert_select "a[href*='page=2'][href*='tab=holdings']", count: 0
|
|
end
|
|
|
|
test "account activity constrains long category labels before the amount on wide screens" do
|
|
category = categories(:food_and_drink)
|
|
category.update!(name: "Super Long Category Name That Should Stop Before The Amount On Wide Screens Too")
|
|
|
|
entry = @account.entries.create!(
|
|
name: "Wide category verification",
|
|
date: Date.current,
|
|
amount: 187.65,
|
|
currency: @account.currency,
|
|
entryable: Transaction.new(category: category)
|
|
)
|
|
|
|
get account_url(@account, tab: "activity")
|
|
|
|
assert_response :success
|
|
assert_select "##{dom_id(entry.entryable, "category_menu_desktop")}"
|
|
assert_select "##{dom_id(entry.entryable, "category_menu_desktop")}.min-w-0"
|
|
assert_select "##{dom_id(entry.entryable, "category_menu_desktop")}.overflow-hidden"
|
|
assert_select "##{dom_id(entry.entryable, "category_menu_desktop")} button.block"
|
|
assert_select "##{dom_id(entry.entryable, "category_menu_desktop")} button.w-full"
|
|
assert_select "##{dom_id(entry.entryable, "category_menu_desktop")} button.overflow-hidden"
|
|
assert_select "##{dom_id(entry.entryable, "category_menu_desktop")} [data-testid='category-name']"
|
|
assert_select "div.hidden.md\\:flex.min-w-0"
|
|
end
|
|
|
|
test "should sync account" do
|
|
post sync_account_url(@account)
|
|
assert_redirected_to account_url(@account)
|
|
end
|
|
|
|
test "should get sparkline" do
|
|
get sparkline_account_url(@account)
|
|
assert_response :success
|
|
end
|
|
|
|
test "destroys account" do
|
|
delete account_url(@account)
|
|
assert_redirected_to accounts_path
|
|
assert_enqueued_with job: DestroyJob
|
|
assert_equal "Depository account scheduled for deletion", flash[:notice]
|
|
end
|
|
|
|
test "syncing linked account triggers sync for all provider items" do
|
|
plaid_account = plaid_accounts(:one)
|
|
AccountProvider.create!(account: @account, provider: plaid_account)
|
|
|
|
# Reload to ensure the account has the provider association loaded
|
|
@account.reload
|
|
|
|
# Mock at the class level since controller loads account from DB
|
|
Account.any_instance.expects(:syncing?).returns(false)
|
|
PlaidItem.any_instance.expects(:syncing?).returns(false)
|
|
PlaidItem.any_instance.expects(:sync_later).once
|
|
|
|
post sync_account_url(@account)
|
|
assert_redirected_to account_url(@account)
|
|
end
|
|
|
|
test "syncing unlinked account calls account sync_later" do
|
|
Account.any_instance.expects(:syncing?).returns(false)
|
|
Account.any_instance.expects(:sync_later).once
|
|
|
|
post sync_account_url(@account)
|
|
assert_redirected_to account_url(@account)
|
|
end
|
|
|
|
test "confirms unlink for linked account" do
|
|
plaid_account = plaid_accounts(:one)
|
|
AccountProvider.create!(account: @account, provider: plaid_account)
|
|
|
|
get confirm_unlink_account_url(@account)
|
|
assert_response :success
|
|
end
|
|
|
|
test "redirects when confirming unlink for unlinked account" do
|
|
get confirm_unlink_account_url(@account)
|
|
assert_redirected_to account_url(@account)
|
|
assert_equal "Account is not linked to a provider", flash[:alert]
|
|
end
|
|
|
|
test "unlinks linked account successfully with new system" do
|
|
plaid_account = plaid_accounts(:one)
|
|
AccountProvider.create!(account: @account, provider: plaid_account)
|
|
@account.reload
|
|
|
|
assert @account.linked?
|
|
|
|
delete unlink_account_url(@account)
|
|
@account.reload
|
|
|
|
assert_not @account.linked?
|
|
assert_redirected_to accounts_path
|
|
assert_equal "Account unlinked successfully. It is now a manual account.", flash[:notice]
|
|
end
|
|
|
|
test "unlinks linked account successfully with legacy system" do
|
|
plaid_account = plaid_accounts(:one)
|
|
@account.update!(plaid_account_id: plaid_account.id)
|
|
@account.reload
|
|
|
|
assert @account.linked?
|
|
|
|
delete unlink_account_url(@account)
|
|
@account.reload
|
|
|
|
assert_not @account.linked?
|
|
assert_nil @account.plaid_account_id
|
|
assert_redirected_to accounts_path
|
|
assert_equal "Account unlinked successfully. It is now a manual account.", flash[:notice]
|
|
end
|
|
|
|
test "redirects when unlinking unlinked account" do
|
|
delete unlink_account_url(@account)
|
|
assert_redirected_to account_url(@account)
|
|
assert_equal "Account is not linked to a provider", flash[:alert]
|
|
end
|
|
|
|
test "unlinked account can be deleted" do
|
|
plaid_account = plaid_accounts(:one)
|
|
AccountProvider.create!(account: @account, provider: plaid_account)
|
|
@account.reload
|
|
|
|
# Cannot delete while linked
|
|
delete account_url(@account)
|
|
assert_redirected_to account_url(@account)
|
|
assert_equal "Cannot delete a linked account. Please unlink it first.", flash[:alert]
|
|
|
|
# Unlink the account
|
|
delete unlink_account_url(@account)
|
|
@account.reload
|
|
|
|
# Now can delete
|
|
delete account_url(@account)
|
|
assert_redirected_to accounts_path
|
|
assert_enqueued_with job: DestroyJob
|
|
assert_equal "Depository account scheduled for deletion", flash[:notice]
|
|
end
|
|
|
|
test "disabling an account keeps it visible on index" do
|
|
@account.disable!
|
|
|
|
get accounts_path
|
|
|
|
assert_response :success
|
|
assert_includes @response.body, @account.name
|
|
end
|
|
|
|
test "toggle_active disables and re-enables an account" do
|
|
patch toggle_active_account_url(@account)
|
|
assert_redirected_to accounts_path
|
|
@account.reload
|
|
assert @account.disabled?
|
|
|
|
patch toggle_active_account_url(@account)
|
|
assert_redirected_to accounts_path
|
|
@account.reload
|
|
assert @account.active?
|
|
end
|
|
|
|
test "toggle_exclude_from_reports toggles the flag on an account" do
|
|
assert_not @account.exclude_from_reports?
|
|
|
|
patch toggle_exclude_from_reports_account_url(@account)
|
|
assert_redirected_to accounts_path
|
|
@account.reload
|
|
assert @account.exclude_from_reports?
|
|
|
|
patch toggle_exclude_from_reports_account_url(@account)
|
|
assert_redirected_to accounts_path
|
|
@account.reload
|
|
assert_not @account.exclude_from_reports?
|
|
end
|
|
|
|
test "toggle_exclude_from_reports requires write permission" do
|
|
sign_in users(:family_member)
|
|
|
|
patch toggle_exclude_from_reports_account_url(accounts(:credit_card))
|
|
assert_redirected_to account_url(accounts(:credit_card))
|
|
end
|
|
|
|
test "select_provider shows available providers" do
|
|
get select_provider_account_url(@account)
|
|
assert_response :success
|
|
end
|
|
|
|
test "set_default sets user default account" do
|
|
patch set_default_account_url(@account)
|
|
assert_redirected_to accounts_path
|
|
@user.reload
|
|
assert_equal @account.id, @user.default_account_id
|
|
end
|
|
|
|
test "set_default rejects ineligible account type" do
|
|
investment = accounts(:investment)
|
|
|
|
patch set_default_account_url(investment)
|
|
assert_redirected_to accounts_path
|
|
assert_equal I18n.t("accounts.set_default.depository_only"), flash[:alert]
|
|
|
|
@user.reload
|
|
assert_not_equal investment.id, @user.default_account_id
|
|
end
|
|
|
|
test "remove_default clears user default account" do
|
|
@user.update!(default_account: @account)
|
|
|
|
patch remove_default_account_url(@account)
|
|
assert_redirected_to accounts_path
|
|
|
|
@user.reload
|
|
assert_nil @user.default_account_id
|
|
end
|
|
|
|
test "select_provider redirects for already linked account" do
|
|
plaid_account = plaid_accounts(:one)
|
|
AccountProvider.create!(account: @account, provider: plaid_account)
|
|
|
|
get select_provider_account_url(@account)
|
|
assert_redirected_to account_url(@account)
|
|
assert_equal "Account is already linked to a provider", flash[:alert]
|
|
end
|
|
|
|
test "unlink preserves SnaptradeAccount record" do
|
|
snaptrade_account = snaptrade_accounts(:fidelity_401k)
|
|
investment = accounts(:investment)
|
|
AccountProvider.create!(account: investment, provider: snaptrade_account)
|
|
investment.reload
|
|
|
|
assert investment.linked?
|
|
|
|
delete unlink_account_url(investment)
|
|
investment.reload
|
|
|
|
assert_not investment.linked?
|
|
assert_redirected_to accounts_path
|
|
# SnaptradeAccount should still exist (not destroyed)
|
|
assert SnaptradeAccount.exists?(snaptrade_account.id), "SnaptradeAccount should be preserved after unlink"
|
|
# But AccountProvider should be gone
|
|
assert_not AccountProvider.exists?(provider_type: "SnaptradeAccount", provider_id: snaptrade_account.id)
|
|
end
|
|
|
|
test "unlink does not enqueue SnapTrade cleanup job" do
|
|
snaptrade_account = snaptrade_accounts(:fidelity_401k)
|
|
investment = accounts(:investment)
|
|
AccountProvider.create!(account: investment, provider: snaptrade_account)
|
|
investment.reload
|
|
|
|
assert_no_enqueued_jobs(only: SnaptradeConnectionCleanupJob) do
|
|
delete unlink_account_url(investment)
|
|
end
|
|
end
|
|
|
|
test "unlink detaches holdings from SnapTrade provider" do
|
|
snaptrade_account = snaptrade_accounts(:fidelity_401k)
|
|
investment = accounts(:investment)
|
|
ap = AccountProvider.create!(account: investment, provider: snaptrade_account)
|
|
|
|
# Assign a holding to this provider
|
|
holding = holdings(:one)
|
|
holding.update!(account_provider: ap)
|
|
|
|
delete unlink_account_url(investment)
|
|
holding.reload
|
|
|
|
assert_nil holding.account_provider_id, "Holding should be detached from provider after unlink"
|
|
end
|
|
|
|
# Regression for #2516: the account sidebar fragment cache renders DS::* view
|
|
# components, which Rails' ERB dependency tracker mis-parses as a bogus "Ds/D"
|
|
# template dependency. With automatic digesting enabled that logged
|
|
# "Couldn't find template for digesting: Ds/D" on every cache miss. The
|
|
# fragment is manually versioned and opts out of digesting via skip_digest.
|
|
test "sidebar fragment cache does not log a bogus template digest error" do
|
|
log = StringIO.new
|
|
logger = ActiveSupport::Logger.new(log)
|
|
|
|
original_perform_caching = ActionController::Base.perform_caching
|
|
original_view_logger = ActionView::Base.logger
|
|
original_rails_logger = Rails.logger
|
|
|
|
ActionController::Base.perform_caching = true
|
|
ActionView::Base.logger = logger
|
|
Rails.logger = logger
|
|
|
|
get accounts_path
|
|
assert_response :success
|
|
assert_no_match(/Couldn't find template for digesting/, log.string)
|
|
ensure
|
|
ActionController::Base.perform_caching = original_perform_caching
|
|
ActionView::Base.logger = original_view_logger
|
|
Rails.logger = original_rails_logger
|
|
end
|
|
end
|
|
|
|
class AccountsControllerSimplefinCtaTest < ActionDispatch::IntegrationTest
|
|
fixtures :users, :families
|
|
|
|
setup do
|
|
sign_in users(:family_admin)
|
|
@family = families(:dylan_family)
|
|
end
|
|
|
|
test "when unlinked SFAs exist and manuals exist, shows setup button only" do
|
|
item = SimplefinItem.create!(family: @family, name: "Conn", access_url: "https://example.com/access")
|
|
# Unlinked SFA (no account and no provider link)
|
|
item.simplefin_accounts.create!(name: "A", account_id: "sf_a", currency: "USD", current_balance: 1, account_type: "depository")
|
|
# One manual account available
|
|
Account.create!(family: @family, name: "Manual A", currency: "USD", balance: 0, accountable_type: "Depository", accountable: Depository.create!(subtype: "checking"))
|
|
|
|
get accounts_path
|
|
assert_response :success
|
|
# Expect setup link present
|
|
assert_includes @response.body, setup_accounts_simplefin_item_path(item)
|
|
# Relink modal (SimpleFin-specific) should not be present anymore
|
|
refute_includes @response.body, "Link existing accounts"
|
|
end
|
|
|
|
test "when SFAs exist and none unlinked and manuals exist, no relink modal is shown (unified flow)" do
|
|
item = SimplefinItem.create!(family: @family, name: "Conn2", access_url: "https://example.com/access")
|
|
# Create a manual linked to SFA so unlinked count == 0
|
|
sfa = item.simplefin_accounts.create!(name: "B", account_id: "sf_b", currency: "USD", current_balance: 1, account_type: "depository")
|
|
linked = Account.create!(family: @family, name: "Linked", currency: "USD", balance: 0, accountable_type: "Depository", accountable: Depository.create!(subtype: "savings"))
|
|
# Legacy association sufficient to count as linked
|
|
sfa.update!(account: linked)
|
|
|
|
# Also create another manual account to make manuals_exist true
|
|
Account.create!(family: @family, name: "Manual B", currency: "USD", balance: 0, accountable_type: "Depository", accountable: Depository.create!(subtype: "checking"))
|
|
|
|
get accounts_path
|
|
assert_response :success
|
|
# The SimpleFin-specific relink modal is removed in favor of unified provider flow
|
|
refute_includes @response.body, "Link existing accounts"
|
|
end
|
|
|
|
test "when no SFAs exist, shows neither CTA" do
|
|
item = SimplefinItem.create!(family: @family, name: "Conn3", access_url: "https://example.com/access")
|
|
|
|
get accounts_path
|
|
assert_response :success
|
|
refute_includes @response.body, setup_accounts_simplefin_item_path(item)
|
|
refute_includes @response.body, "Link existing accounts"
|
|
end
|
|
end
|