Files
sure/test/models/onchain_wallet_account_test.rb
T
buzzromainandClaude Opus 5 d7bf401cc7 fix(onchain-wallets): call transfers transfers, and drop the address swap (#3153)
* 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>
2026-08-26 21:07:58 +02:00

224 lines
8.1 KiB
Ruby

# frozen_string_literal: true
require "test_helper"
class OnchainWalletAccountTest < ActiveSupport::TestCase
include OnchainTestHelper
setup do
register_fake_chain!
@family = families(:dylan_family)
@item = create_onchain_wallet_item(family: @family)
end
teardown do
unregister_fake_chain!
end
test "the database rejects a second native row for the same address" do
create_onchain_wallet_account(item: @item)
duplicate = @item.onchain_wallet_accounts.new(
chain: OnchainTestHelper::FAKE_CHAIN,
wallet_address: OnchainTestHelper::FAKE_ADDRESS,
asset_kind: "native",
symbol: "FAKE",
decimals: 8,
quantity: 3,
currency: "USD"
)
# save(validate: false) proves the guarantee comes from the partial unique
# index and not from the ActiveRecord validation above it.
assert_raises ActiveRecord::RecordNotUnique do
duplicate.save!(validate: false)
end
end
test "the database rejects a second token row for the same contract" do
create_onchain_wallet_account(item: @item, asset: fake_token_asset(contract: "0xAAA"))
duplicate = @item.onchain_wallet_accounts.new(
chain: OnchainTestHelper::FAKE_CHAIN,
wallet_address: OnchainTestHelper::FAKE_ADDRESS,
asset_kind: OnchainTestHelper::FAKE_TOKEN_KIND,
contract_address: "0xaaa",
symbol: "FUSD",
decimals: 6,
quantity: 1,
currency: "USD"
)
assert_raises ActiveRecord::RecordNotUnique do
duplicate.save!(validate: false)
end
end
test "the database rejects a token row with no contract address" do
orphan = @item.onchain_wallet_accounts.new(
chain: OnchainTestHelper::FAKE_CHAIN,
wallet_address: OnchainTestHelper::FAKE_ADDRESS,
asset_kind: OnchainTestHelper::FAKE_TOKEN_KIND,
contract_address: nil,
symbol: "FUSD",
decimals: 6,
quantity: 1,
currency: "USD"
)
# A token is keyed on its contract, and NULLs are distinct to Postgres, so
# this row would slip past the partial unique index and duplicate freely.
# The model refuses it too; validate: false proves the table does.
assert_raises ActiveRecord::StatementInvalid do
orphan.save!(validate: false)
end
end
test "the database rejects an asset kind no index keys on" do
odd = @item.onchain_wallet_accounts.new(
chain: OnchainTestHelper::FAKE_CHAIN,
wallet_address: OnchainTestHelper::FAKE_ADDRESS,
asset_kind: "trc20",
contract_address: "0xccc",
symbol: "FUSD",
decimals: 6,
quantity: 1,
currency: "USD"
)
# Each partial unique index names its kind, so a row carrying another one is
# keyed by nothing and would duplicate freely.
assert_raises ActiveRecord::StatementInvalid do
odd.save!(validate: false)
end
end
test "unlinking the account drops the tracking row and leaves the account manual" do
onchain_account = create_onchain_wallet_account(item: @item)
account = accounts(:investment)
provider_link = onchain_account.ensure_account_provider!(account)
holding = holdings(:one)
holding.update!(account_provider: provider_link)
assert_difference "OnchainWalletAccount.count", -1 do
account.account_providers.destroy_all
end
# What the user asked for is to stop tracking the address, not to lose the
# account: the Sure account and its holdings stay, as manual ones.
assert Account.exists?(account.id)
assert Holding.exists?(holding.id)
assert_nil holding.reload.account_provider_id
# Left behind, the row would stop syncing while its partial unique index
# still held the slot, so linking the same asset again would collide.
assert_not OnchainWalletAccount.exists?(onchain_account.id)
end
test "deleting the account takes the tracking row with it" do
onchain_account = create_onchain_wallet_account(item: @item)
account = link_onchain_wallet_account!(onchain_account)
# Same reasoning as unlinking, by a route that used to miss it: the link is
# destroyed by the account here rather than by the row, and a guard meant to
# stop the row destroying itself was catching this case too.
assert_difference "OnchainWalletAccount.count", -1 do
account.destroy!
end
assert_not OnchainWalletAccount.exists?(onchain_account.id)
end
test "a linked account takes its icon from the asset it tracks" do
# Saved rather than assumed nil: `Setting` is application-wide, and an
# `ensure` that hard-codes nil erases whatever the suite had configured,
# leaving every later test's behaviour dependent on run order.
previous_client_id = Setting.brand_fetch_client_id
Setting.brand_fetch_client_id = "test-client"
onchain_account = create_onchain_wallet_account(item: @item)
account = accounts(:investment)
onchain_account.ensure_account_provider!(account)
# There is no institution behind a wallet and nothing attaches a file, so
# without this the accounts page shows a tracked asset with no icon at all.
assert_includes account.reload.logo_url.to_s, "/crypto/#{onchain_account.symbol}/"
ensure
Setting.brand_fetch_client_id = previous_client_id
end
test "a native row and token rows coexist for one address" do
create_onchain_wallet_account(item: @item)
create_onchain_wallet_account(item: @item, asset: fake_token_asset(contract: "0xaaa"))
create_onchain_wallet_account(item: @item, asset: fake_token_asset(symbol: "FDAI", contract: "0xbbb"))
assert_equal 3, @item.onchain_wallet_accounts.count
end
test "the same asset is tracked separately per address" do
create_onchain_wallet_account(item: @item)
other = create_onchain_wallet_account(item: @item, address: OnchainTestHelper::FAKE_ADDRESS_ALT)
assert other.persisted?
assert_equal 2, @item.onchain_wallet_accounts.count
end
test "requires a registered chain" do
account = @item.onchain_wallet_accounts.new(
chain: "nosuchchain",
wallet_address: OnchainTestHelper::FAKE_ADDRESS,
asset_kind: "native",
symbol: "FAKE",
currency: "USD",
quantity: 1
)
assert_not account.valid?
assert_includes account.errors.attribute_names, :chain
end
test "rejects an asset kind the chain does not support" do
account = @item.onchain_wallet_accounts.new(
chain: OnchainTestHelper::FAKE_CHAIN,
wallet_address: OnchainTestHelper::FAKE_ADDRESS,
asset_kind: "spl",
contract_address: "0xaaa",
symbol: "FUSD",
currency: "USD",
quantity: 1
)
assert_not account.valid?
assert_includes account.errors.attribute_names, :asset_kind
end
test "native rows must not carry a contract and token rows must" do
native_with_contract = @item.onchain_wallet_accounts.new(
chain: OnchainTestHelper::FAKE_CHAIN, wallet_address: OnchainTestHelper::FAKE_ADDRESS,
asset_kind: "native", contract_address: "0xaaa", symbol: "FAKE", currency: "USD", quantity: 1
)
token_without_contract = @item.onchain_wallet_accounts.new(
chain: OnchainTestHelper::FAKE_CHAIN, wallet_address: OnchainTestHelper::FAKE_ADDRESS,
asset_kind: OnchainTestHelper::FAKE_TOKEN_KIND, symbol: "FUSD", currency: "USD", quantity: 1
)
assert_not native_with_contract.valid?
assert_not token_without_contract.valid?
assert_includes native_with_contract.errors.attribute_names, :contract_address
assert_includes token_without_contract.errors.attribute_names, :contract_address
end
test "contract addresses are stored downcased so matching is case-insensitive" do
account = create_onchain_wallet_account(item: @item, asset: fake_token_asset(contract: "0xAbCdEf"))
assert_equal "0xabcdef", account.contract_address
assert account.matches_asset?(fake_token_asset(contract: "0xABCDEF"))
assert_not account.matches_asset?(fake_native_asset)
end
test "ordered puts the native asset of each wallet first" do
token_asset = create_onchain_wallet_account(item: @item, asset: fake_token_asset(symbol: "AAA", contract: "0xaaa"))
native = create_onchain_wallet_account(item: @item)
assert_equal [ native.id, token_asset.id ], @item.onchain_wallet_accounts.ordered.map(&:id)
end
end