mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 23:01:26 +00:00
* fix(onchain): a deleted account leaves no wallet row behind Deleting a Sure account left its on-chain tracking row in place. The row had a callback to follow its account link into the grave, but the guard that stopped it destroying itself could not tell apart the two ways that link dies — by the row, or by the account — and so caught both. What survived was worse than untidy. The row synced nothing and showed nowhere, yet it still answered "yes" to "is this address already tracked?" and still held the asset's slot in the partial unique index. The address became unusable: adding it again was refused as a duplicate of something the user could not see, and moving another address onto it raised a database error. So three places learn the difference between a row that tracks an account and one that merely exists: the guard now skips only its own row's destruction, the family's linked check asks for a live link, and the linker and address change let a dead row make way instead of colliding with it. No migration: a row already orphaned on a running instance is absorbed the next time that address is tracked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nN1GZi7Dv3d7yZUozw3Yo * fix(onchain): stop an orphan row passing itself off as a tracked asset Review on #3182. Both findings are the same root: a row left behind by a deleted account has no Account and no AccountProvider, but nothing downstream was asking. `revise` counted any matching row as tracked, orphans included, so ticking an asset whose account had been deleted did nothing at all — no Account created, the row still orphaned, and the screen still reporting the asset as tracked. It now looks only at rows that are actually linked, and at the ones that survived the removal pass. `link` cleared only the rows matching the assets picked in that submit, so relinking a subset left the rest as orphans. They still show up in `grouped_accounts` and token review as tracked, and — before the fix above — they also stopped `revise` from ever rebuilding them. Reclaiming an address now clears every row it left behind. A live row is untouched, with a test that fails if the sweep widens. One existing test had to change its premise rather than its assertion. It built two unlinked rows and asserted the surviving row object was the same one; unlinked rows are orphans, so revising now rebuilds them and the survivor is a new row. Linking them first is what a tracked asset actually is, and the assertion then measures the removal it was written for. 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>
672 lines
27 KiB
Ruby
672 lines
27 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "test_helper"
|
|
|
|
class OnchainWalletItemsControllerTest < ActionDispatch::IntegrationTest
|
|
include OnchainTestHelper
|
|
|
|
setup do
|
|
sign_in users(:family_admin)
|
|
SyncJob.stubs(:perform_later)
|
|
register_fake_chain!
|
|
@family = families(:dylan_family)
|
|
end
|
|
|
|
teardown do
|
|
unregister_fake_chain!
|
|
end
|
|
|
|
test "new_wallet renders the address form with the read-only and Bitcoin notes" do
|
|
get new_wallet_onchain_wallet_items_url
|
|
|
|
assert_response :success
|
|
assert_match I18n.t("onchain_wallet_items.new_wallet.bitcoin_single_address_note"), response.body
|
|
assert_match I18n.t("onchain_wallet_items.new_wallet.read_only_note"), response.body
|
|
end
|
|
|
|
test "the settings panel renders before anything is linked" do
|
|
get connect_form_settings_providers_path(provider_key: "onchain_wallet")
|
|
|
|
assert_response :success
|
|
assert_match I18n.t("settings.providers.onchain_wallet_panel.keyless_title"), response.body
|
|
assert_select "a[href=?]", new_wallet_onchain_wallet_items_path
|
|
end
|
|
|
|
test "the settings panel renders the tracked wallets and the connection settings" do
|
|
item = create_onchain_wallet_item(family: @family)
|
|
create_onchain_wallet_account(item: item)
|
|
|
|
get connect_form_settings_providers_path(provider_key: "onchain_wallet")
|
|
|
|
assert_response :success
|
|
assert_match OnchainTestHelper::FAKE_ADDRESS.first(6), response.body
|
|
assert_match I18n.t("settings.providers.onchain_wallet_panel.etherscan_api_key_label"), response.body
|
|
assert_match I18n.t("settings.providers.onchain_wallet_panel.sync_start_date_label"), response.body
|
|
assert_select "form[action=?]", sync_onchain_wallet_item_path(item)
|
|
assert_select "a[href=?]", manage_onchain_wallet_item_path(item)
|
|
end
|
|
|
|
test "the price provider warning appears only when no crypto provider is enabled" do
|
|
get new_wallet_onchain_wallet_items_url
|
|
assert_match I18n.t("onchain_wallet_items.price_provider_warning.title"), response.body
|
|
|
|
Setting.stubs(:enabled_securities_providers).returns([ Onchain::SecurityResolver::PRICE_PROVIDER ])
|
|
|
|
get new_wallet_onchain_wallet_items_url
|
|
assert_no_match I18n.t("onchain_wallet_items.price_provider_warning.title"), response.body
|
|
end
|
|
|
|
test "a non-USD family is warned that nothing can convert USD prices" do
|
|
Setting.stubs(:enabled_securities_providers).returns([ Onchain::SecurityResolver::PRICE_PROVIDER ])
|
|
ExchangeRate.stubs(:provider).returns(nil)
|
|
@family.update!(currency: "EUR")
|
|
|
|
get new_wallet_onchain_wallet_items_url
|
|
|
|
assert_match I18n.t("onchain_wallet_items.price_provider_warning.exchange_rate_title"), response.body
|
|
assert_no_match I18n.t("onchain_wallet_items.price_provider_warning.title"), response.body
|
|
end
|
|
|
|
test "a USD family with crypto prices on sees no pricing warning at all" do
|
|
Setting.stubs(:enabled_securities_providers).returns([ Onchain::SecurityResolver::PRICE_PROVIDER ])
|
|
|
|
get new_wallet_onchain_wallet_items_url
|
|
|
|
assert_no_match I18n.t("onchain_wallet_items.price_provider_warning.title"), response.body
|
|
assert_no_match I18n.t("onchain_wallet_items.price_provider_warning.exchange_rate_title"), response.body
|
|
end
|
|
|
|
test "the warning offers a one-click fix on a self-hosted instance" do
|
|
Rails.configuration.stubs(:app_mode).returns(ActiveSupport::StringInquirer.new("self_hosted"))
|
|
|
|
get new_wallet_onchain_wallet_items_url
|
|
|
|
assert_select "form[action=?]", enable_crypto_prices_onchain_wallet_items_path
|
|
end
|
|
|
|
test "enable_crypto_prices adds the crypto provider without disabling the others" do
|
|
Rails.configuration.stubs(:app_mode).returns(ActiveSupport::StringInquirer.new("self_hosted"))
|
|
Setting.securities_providers = "twelve_data"
|
|
|
|
post enable_crypto_prices_onchain_wallet_items_url
|
|
|
|
assert_response :redirect
|
|
assert Onchain::SecurityResolver.price_provider_enabled?
|
|
assert_includes Setting.enabled_securities_providers, "twelve_data"
|
|
end
|
|
|
|
test "enable_crypto_prices is refused on a managed instance where the operator decides" do
|
|
Setting.securities_providers = "twelve_data"
|
|
|
|
post enable_crypto_prices_onchain_wallet_items_url
|
|
|
|
assert_response :redirect
|
|
assert_not Onchain::SecurityResolver.price_provider_enabled?
|
|
assert_equal I18n.t("onchain_wallet_items.enable_crypto_prices.not_self_hosted"), flash[:alert]
|
|
end
|
|
|
|
test "a member without admin rights is not offered the one-click fix" do
|
|
Rails.configuration.stubs(:app_mode).returns(ActiveSupport::StringInquirer.new("self_hosted"))
|
|
sign_in users(:family_member)
|
|
|
|
get manage_onchain_wallet_item_url(create_onchain_wallet_item(family: @family))
|
|
|
|
assert_response :redirect
|
|
end
|
|
|
|
test "preview_wallet lists what the address holds, pre-checking only vouched-for assets" do
|
|
stub_fake_snapshot(
|
|
OnchainTestHelper::FAKE_ADDRESS,
|
|
Onchain::Snapshot.new(
|
|
assets: [
|
|
fake_native_asset(quantity: 2),
|
|
fake_token_asset(symbol: "USDC", contract: "0xusdc", quantity: 10),
|
|
fake_token_asset(symbol: "Visit site to claim", contract: "0xspam", quantity: 1),
|
|
# An airdrop with a perfectly plausible symbol: only the data source's
|
|
# own signal separates it from the real thing.
|
|
fake_token_asset(symbol: "0XBTC", contract: "0xairdrop", quantity: 1, notable: false)
|
|
],
|
|
movements: []
|
|
)
|
|
)
|
|
|
|
post preview_wallet_onchain_wallet_items_url, params: { address: OnchainTestHelper::FAKE_ADDRESS }
|
|
|
|
assert_response :success
|
|
assert_match "USDC", response.body
|
|
assert_match I18n.t("onchain_wallet_items.token_review_form.no_price"), response.body
|
|
assert_select "input[name='assets[]'][value='native'][checked]"
|
|
assert_select "input[name='assets[]'][value='erc20:0xusdc'][checked]"
|
|
assert_select "input[name='assets[]'][value='erc20:0xspam']"
|
|
assert_select "input[name='assets[]'][value='erc20:0xspam'][checked]", false
|
|
assert_select "input[name='assets[]'][value='erc20:0xairdrop']"
|
|
assert_select "input[name='assets[]'][value='erc20:0xairdrop'][checked]", false
|
|
end
|
|
|
|
test "preview_wallet rejects an address that belongs to no supported chain" do
|
|
post preview_wallet_onchain_wallet_items_url, params: { address: "definitely-not-an-address" }
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_match I18n.t("onchain_wallet_items.errors.unrecognized_address"), response.body
|
|
end
|
|
|
|
test "preview_wallet asks which chain when several report activity" do
|
|
other = "otherchain"
|
|
Onchain::Chains.register(
|
|
Onchain::Chains::Definition.new(
|
|
key: other,
|
|
native: Onchain::Chains::NativeAsset.new(symbol: "OTHER", name: "Other", decimals: 8),
|
|
token_kind: nil,
|
|
adapter_class_name: "OnchainTestHelper::FakeAdapter",
|
|
adapter_options: {}
|
|
)
|
|
)
|
|
|
|
post preview_wallet_onchain_wallet_items_url, params: { address: OnchainTestHelper::FAKE_ADDRESS }
|
|
|
|
assert_response :success
|
|
assert_match I18n.t("onchain_wallet_items.choose_chain.title"), response.body
|
|
assert_match I18n.t("onchain_wallet_items.choose_chain.activity_found"), response.body
|
|
ensure
|
|
Onchain::Chains.unregister(other)
|
|
end
|
|
|
|
test "an expected explorer failure surfaces as a localized message" do
|
|
OnchainTestHelper::FakeAdapter.error = Onchain::Chains::UnreachableError.new("blockscout down")
|
|
|
|
post preview_wallet_onchain_wallet_items_url, params: { address: OnchainTestHelper::FAKE_ADDRESS }
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_match I18n.t("onchain_wallet_items.errors.chain_unreachable"), response.body
|
|
assert_no_match "blockscout down", response.body
|
|
end
|
|
|
|
test "a data source that times out reads as unreachable, not as our own failure" do
|
|
OnchainTestHelper::FakeAdapter.error = Provider::MempoolSpace::ApiError.new("MempoolSpace is unavailable (Net::ReadTimeout)")
|
|
OnchainTestHelper::FakeAdapter.provider_error_classes = [ Provider::MempoolSpace::RateLimitError, Provider::MempoolSpace::Error ]
|
|
|
|
assert_no_difference "DebugLogEntry.count" do
|
|
post preview_wallet_onchain_wallet_items_url, params: { address: OnchainTestHelper::FAKE_ADDRESS }
|
|
end
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_match I18n.t("onchain_wallet_items.errors.chain_unreachable"), response.body
|
|
assert_no_match "Net::ReadTimeout", response.body
|
|
end
|
|
|
|
test "an unexpected failure is recorded and never interpolated into the response" do
|
|
OnchainTestHelper::FakeAdapter.error = NoMethodError.new("undefined method 'boom' for nil")
|
|
|
|
assert_difference "DebugLogEntry.count", 1 do
|
|
post preview_wallet_onchain_wallet_items_url, params: { address: OnchainTestHelper::FAKE_ADDRESS }
|
|
end
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_match I18n.t("onchain_wallet_items.errors.unexpected"), response.body
|
|
assert_no_match "undefined method", response.body
|
|
assert_equal "onchain_wallet", DebugLogEntry.order(:created_at).last.provider_key
|
|
end
|
|
|
|
test "link_wallet tracks only the ticked assets and creates one account each" do
|
|
stub_wallet_with_token
|
|
|
|
assert_difference [ "OnchainWalletAccount.count", "Account.count" ], 1 do
|
|
post link_wallet_onchain_wallet_items_url, params: {
|
|
chain: OnchainTestHelper::FAKE_CHAIN,
|
|
address: OnchainTestHelper::FAKE_ADDRESS,
|
|
assets: [ "native" ]
|
|
}
|
|
end
|
|
|
|
assert_redirected_to accounts_path
|
|
onchain_account = OnchainWalletAccount.order(:created_at).last
|
|
assert_equal "native", onchain_account.asset_kind
|
|
assert_equal @family, onchain_account.onchain_wallet_item.family
|
|
assert_equal "Crypto", onchain_account.account.accountable_type
|
|
assert_equal "wallet", onchain_account.account.accountable.subtype
|
|
end
|
|
|
|
test "link_wallet ignores an asset the chain does not report" do
|
|
stub_wallet_with_token
|
|
|
|
assert_difference "OnchainWalletAccount.count", 1 do
|
|
post link_wallet_onchain_wallet_items_url, params: {
|
|
chain: OnchainTestHelper::FAKE_CHAIN,
|
|
address: OnchainTestHelper::FAKE_ADDRESS,
|
|
assets: [ "native", "erc20:0xnothere" ]
|
|
}
|
|
end
|
|
end
|
|
|
|
test "link_wallet refuses an address the family already tracks" do
|
|
item = create_onchain_wallet_item(family: @family)
|
|
link_onchain_wallet_account!(create_onchain_wallet_account(item: item))
|
|
stub_wallet_with_token
|
|
|
|
assert_no_difference "OnchainWalletAccount.count" do
|
|
post link_wallet_onchain_wallet_items_url, params: {
|
|
chain: OnchainTestHelper::FAKE_CHAIN,
|
|
address: OnchainTestHelper::FAKE_ADDRESS,
|
|
assets: [ "native" ]
|
|
}
|
|
end
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_match I18n.t("onchain_wallet_items.errors.already_linked"), response.body
|
|
end
|
|
|
|
test "preview_wallet refuses an address the family already tracks" do
|
|
item = create_onchain_wallet_item(family: @family)
|
|
link_onchain_wallet_account!(create_onchain_wallet_account(item: item))
|
|
|
|
post preview_wallet_onchain_wallet_items_url, params: {
|
|
address: OnchainTestHelper::FAKE_ADDRESS,
|
|
chain: OnchainTestHelper::FAKE_CHAIN
|
|
}
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_match I18n.t("onchain_wallet_items.errors.already_linked"), response.body
|
|
end
|
|
|
|
test "a failed read leaves no empty connection behind" do
|
|
OnchainTestHelper::FakeAdapter.error = Onchain::Chains::UnreachableError.new("explorer down")
|
|
|
|
assert_no_difference "OnchainWalletItem.count" do
|
|
post link_wallet_onchain_wallet_items_url, params: {
|
|
chain: OnchainTestHelper::FAKE_CHAIN,
|
|
address: OnchainTestHelper::FAKE_ADDRESS,
|
|
assets: [ "native" ]
|
|
}
|
|
end
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_match I18n.t("onchain_wallet_items.errors.chain_unreachable"), response.body
|
|
end
|
|
|
|
test "assets that were ticked but could not be tracked are named, not blamed on the user" do
|
|
stub_wallet_with_token
|
|
OnchainWalletItem::WalletLinker.any_instance.stubs(:link).returns(
|
|
OnchainWalletItem::WalletLinker::Result.new(created: 0, removed: 0, errors: [ "USDC" ])
|
|
)
|
|
|
|
post link_wallet_onchain_wallet_items_url, params: {
|
|
chain: OnchainTestHelper::FAKE_CHAIN,
|
|
address: OnchainTestHelper::FAKE_ADDRESS,
|
|
assets: [ "erc20:0xusdc" ]
|
|
}
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_match "USDC", response.body
|
|
# Being told to "pick at least one asset" would send them back to tick the
|
|
# same one.
|
|
assert_no_match I18n.t("onchain_wallet_items.link_wallet.errors.nothing_selected"), response.body
|
|
end
|
|
|
|
test "a partial failure is reported alongside what did get tracked" do
|
|
stub_wallet_with_token
|
|
OnchainWalletItem::WalletLinker.any_instance.stubs(:link).returns(
|
|
OnchainWalletItem::WalletLinker::Result.new(created: 1, removed: 0, errors: [ "USDC" ])
|
|
)
|
|
|
|
post link_wallet_onchain_wallet_items_url, params: {
|
|
chain: OnchainTestHelper::FAKE_CHAIN,
|
|
address: OnchainTestHelper::FAKE_ADDRESS,
|
|
assets: [ "native", "erc20:0xusdc" ]
|
|
}
|
|
|
|
assert_redirected_to accounts_path
|
|
assert_match "USDC", flash[:alert]
|
|
end
|
|
|
|
test "the settings link is not offered to anyone who cannot open that page" do
|
|
# Managed instance: market data is the operator's setting and the page is
|
|
# gated, so a link there is a dead end. Asserted on the link's own text —
|
|
# the settings layout mentions that path for its own reasons.
|
|
link = I18n.t("onchain_wallet_items.price_provider_warning.link")
|
|
|
|
get new_wallet_onchain_wallet_items_url
|
|
assert_match I18n.t("onchain_wallet_items.price_provider_warning.title"), response.body
|
|
assert_no_match link, response.body
|
|
|
|
Rails.configuration.stubs(:app_mode).returns(ActiveSupport::StringInquirer.new("self_hosted"))
|
|
get new_wallet_onchain_wallet_items_url
|
|
assert_match link, response.body
|
|
|
|
sign_in users(:family_member)
|
|
get manage_onchain_wallet_item_url(create_onchain_wallet_item(family: @family))
|
|
assert_response :redirect
|
|
end
|
|
|
|
test "link_wallet with nothing ticked reopens the selection" do
|
|
stub_wallet_with_token
|
|
|
|
assert_no_difference "OnchainWalletAccount.count" do
|
|
post link_wallet_onchain_wallet_items_url, params: {
|
|
chain: OnchainTestHelper::FAKE_CHAIN,
|
|
address: OnchainTestHelper::FAKE_ADDRESS
|
|
}
|
|
end
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_match I18n.t("onchain_wallet_items.link_wallet.errors.nothing_selected"), response.body
|
|
end
|
|
|
|
test "previewing an address never leaves an empty connection behind" do
|
|
stub_wallet_with_token
|
|
|
|
assert_no_difference "OnchainWalletItem.count" do
|
|
post preview_wallet_onchain_wallet_items_url, params: { address: OnchainTestHelper::FAKE_ADDRESS }
|
|
end
|
|
end
|
|
|
|
test "sync schedules a sync for the family's own connection only" do
|
|
item = create_onchain_wallet_item(family: @family)
|
|
other_family_item = create_onchain_wallet_item(family: families(:empty))
|
|
|
|
post sync_onchain_wallet_item_url(item)
|
|
assert_redirected_to settings_providers_path
|
|
|
|
post sync_onchain_wallet_item_url(other_family_item)
|
|
assert_response :not_found
|
|
end
|
|
|
|
test "destroy schedules the connection for deletion" do
|
|
item = create_onchain_wallet_item(family: @family)
|
|
|
|
delete onchain_wallet_item_url(item)
|
|
|
|
assert_redirected_to settings_providers_path
|
|
assert item.reload.scheduled_for_deletion
|
|
end
|
|
|
|
test "update stores an explorer key and keeps it when the field is left blank" do
|
|
item = create_onchain_wallet_item(family: @family)
|
|
|
|
patch onchain_wallet_item_url(item), params: { onchain_wallet_item: { etherscan_api_key: "abc123" } }
|
|
assert_equal "abc123", item.reload.etherscan_api_key
|
|
|
|
patch onchain_wallet_item_url(item), params: { onchain_wallet_item: { etherscan_api_key: "" } }
|
|
assert_equal "abc123", item.reload.etherscan_api_key
|
|
end
|
|
|
|
test "update stores the date before which transfers are ignored" do
|
|
item = create_onchain_wallet_item(family: @family)
|
|
|
|
patch onchain_wallet_item_url(item), params: { onchain_wallet_item: { sync_start_date: "2026-01-15" } }
|
|
|
|
assert_equal Date.new(2026, 1, 15), item.reload.sync_start_date.to_date
|
|
end
|
|
|
|
test "linking creates the single connection every wallet hangs off" do
|
|
stub_wallet_with_token
|
|
stub_wallet_with_token(address: OnchainTestHelper::FAKE_ADDRESS_ALT)
|
|
|
|
assert_difference "OnchainWalletItem.count", 1 do
|
|
post link_wallet_onchain_wallet_items_url, params: {
|
|
chain: OnchainTestHelper::FAKE_CHAIN,
|
|
address: OnchainTestHelper::FAKE_ADDRESS,
|
|
assets: [ "native" ]
|
|
}
|
|
end
|
|
|
|
assert_no_difference "OnchainWalletItem.count" do
|
|
post link_wallet_onchain_wallet_items_url, params: {
|
|
chain: OnchainTestHelper::FAKE_CHAIN,
|
|
address: OnchainTestHelper::FAKE_ADDRESS_ALT,
|
|
assets: [ "native" ]
|
|
}
|
|
end
|
|
|
|
assert_equal 2, @family.reload.onchain_wallet_items.sole.wallet_keys.size
|
|
end
|
|
|
|
test "a member without admin rights cannot link a wallet" do
|
|
sign_in users(:family_member)
|
|
|
|
get new_wallet_onchain_wallet_items_url
|
|
|
|
assert_response :redirect
|
|
end
|
|
|
|
test "manage lists every tracked address and offers all four actions" do
|
|
item = create_onchain_wallet_item(family: @family)
|
|
native = create_onchain_wallet_account(item: item)
|
|
token_asset = create_onchain_wallet_account(item: item, asset: fake_token_asset(symbol: "USDC", contract: "0xusdc"))
|
|
|
|
get manage_onchain_wallet_item_url(item)
|
|
|
|
assert_response :success
|
|
assert_match OnchainTestHelper::FAKE_ADDRESS, response.body
|
|
assert_match I18n.t("onchain_wallet_items.manage.review_tokens"), response.body
|
|
assert_match I18n.t("onchain_wallet_items.manage.change_address"), response.body
|
|
assert_match I18n.t("onchain_wallet_items.manage.disconnect_wallet"), response.body
|
|
# Per-asset action, for each asset, not just one for the wallet.
|
|
assert_select "form[action=?]", disconnect_asset_onchain_wallet_item_path(item), count: 2
|
|
assert_match native.symbol, response.body
|
|
assert_match token_asset.symbol, response.body
|
|
end
|
|
|
|
test "review_tokens reopens the selection with the address unchanged" do
|
|
item = create_onchain_wallet_item(family: @family)
|
|
create_onchain_wallet_account(item: item)
|
|
stub_wallet_with_token
|
|
|
|
get review_tokens_onchain_wallet_item_url(item, chain: OnchainTestHelper::FAKE_CHAIN, address: OnchainTestHelper::FAKE_ADDRESS)
|
|
|
|
assert_response :success
|
|
assert_select "input[name=address][value=?]", OnchainTestHelper::FAKE_ADDRESS
|
|
assert_select "input[name='new_address']", false
|
|
assert_select "input[name='assets[]'][value='native'][checked]"
|
|
assert_select "input[name='assets[]'][value='erc20:0xusdc'][checked]"
|
|
end
|
|
|
|
test "update_tokens adds a newly ticked asset and stops tracking an unticked one" do
|
|
item = create_onchain_wallet_item(family: @family)
|
|
native = create_onchain_wallet_account(item: item)
|
|
stub_wallet_with_token
|
|
|
|
assert_difference "OnchainWalletAccount.count", 0 do
|
|
post update_tokens_onchain_wallet_item_url(item), params: {
|
|
chain: OnchainTestHelper::FAKE_CHAIN,
|
|
address: OnchainTestHelper::FAKE_ADDRESS,
|
|
assets: [ "erc20:0xusdc" ]
|
|
}
|
|
end
|
|
|
|
assert_redirected_to manage_onchain_wallet_item_path(item)
|
|
assert_not OnchainWalletAccount.exists?(native.id)
|
|
assert_equal [ "USDC" ], item.reload.onchain_wallet_accounts.pluck(:symbol)
|
|
end
|
|
|
|
test "unticking one asset removes only it" do
|
|
item = create_onchain_wallet_item(family: @family)
|
|
# Linked, because that is what a tracked asset actually is. Left unlinked
|
|
# these are orphan rows, and revising now rebuilds those into real ones —
|
|
# so the surviving row would be a new one and the assertion below would be
|
|
# measuring the repair rather than the removal.
|
|
native = create_onchain_wallet_account(item: item)
|
|
link_onchain_wallet_account!(native)
|
|
token_asset = create_onchain_wallet_account(item: item, asset: fake_token_asset(symbol: "USDC", contract: "0xusdc"))
|
|
link_onchain_wallet_account!(token_asset)
|
|
stub_wallet_with_token
|
|
|
|
post update_tokens_onchain_wallet_item_url(item), params: {
|
|
chain: OnchainTestHelper::FAKE_CHAIN,
|
|
address: OnchainTestHelper::FAKE_ADDRESS,
|
|
assets: [ "native" ]
|
|
}
|
|
|
|
assert OnchainWalletAccount.exists?(native.id)
|
|
assert_not OnchainWalletAccount.exists?(token_asset.id)
|
|
end
|
|
|
|
test "an asset the chain no longer reports can still be unticked" do
|
|
item = create_onchain_wallet_item(family: @family)
|
|
gone = create_onchain_wallet_account(item: item, asset: fake_token_asset(symbol: "GONE", contract: "0xgone"))
|
|
create_onchain_wallet_account(item: item)
|
|
stub_wallet_with_token
|
|
|
|
get review_tokens_onchain_wallet_item_url(item, chain: OnchainTestHelper::FAKE_CHAIN, address: OnchainTestHelper::FAKE_ADDRESS)
|
|
assert_select "input[name='assets[]'][value='erc20:0xgone'][checked]"
|
|
|
|
post update_tokens_onchain_wallet_item_url(item), params: {
|
|
chain: OnchainTestHelper::FAKE_CHAIN,
|
|
address: OnchainTestHelper::FAKE_ADDRESS,
|
|
assets: [ "native" ]
|
|
}
|
|
|
|
assert_not OnchainWalletAccount.exists?(gone.id)
|
|
end
|
|
|
|
test "change_address keeps the rows, their accounts and their history" do
|
|
item = create_onchain_wallet_item(family: @family)
|
|
onchain_account = create_onchain_wallet_account(item: item)
|
|
account = link_onchain_wallet_account!(onchain_account)
|
|
balance = account.balances.create!(date: Date.current, balance: 100, cash_balance: 0, currency: account.currency)
|
|
|
|
patch change_address_onchain_wallet_item_url(item), params: {
|
|
chain: OnchainTestHelper::FAKE_CHAIN,
|
|
address: OnchainTestHelper::FAKE_ADDRESS,
|
|
new_address: OnchainTestHelper::FAKE_ADDRESS_ALT
|
|
}
|
|
|
|
assert_redirected_to manage_onchain_wallet_item_path(item)
|
|
onchain_account.reload
|
|
assert_equal OnchainTestHelper::FAKE_ADDRESS_ALT, onchain_account.wallet_address
|
|
assert_equal account, onchain_account.account
|
|
assert Account.exists?(account.id)
|
|
assert account.balances.exists?(balance.id)
|
|
assert_nil onchain_account.content_hash
|
|
end
|
|
|
|
test "change_address refuses an address that is invalid for the chain" do
|
|
item = create_onchain_wallet_item(family: @family)
|
|
onchain_account = create_onchain_wallet_account(item: item)
|
|
|
|
patch change_address_onchain_wallet_item_url(item), params: {
|
|
chain: OnchainTestHelper::FAKE_CHAIN,
|
|
address: OnchainTestHelper::FAKE_ADDRESS,
|
|
new_address: "not-an-address"
|
|
}
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_match I18n.t("onchain_wallet_items.change_address.errors.invalid_address"), response.body
|
|
assert_equal OnchainTestHelper::FAKE_ADDRESS, onchain_account.reload.wallet_address
|
|
end
|
|
|
|
test "change_address refuses an address already tracked elsewhere" do
|
|
item = create_onchain_wallet_item(family: @family)
|
|
link_onchain_wallet_account!(create_onchain_wallet_account(item: item))
|
|
link_onchain_wallet_account!(create_onchain_wallet_account(item: item, address: OnchainTestHelper::FAKE_ADDRESS_ALT))
|
|
|
|
patch change_address_onchain_wallet_item_url(item), params: {
|
|
chain: OnchainTestHelper::FAKE_CHAIN,
|
|
address: OnchainTestHelper::FAKE_ADDRESS,
|
|
new_address: OnchainTestHelper::FAKE_ADDRESS_ALT
|
|
}
|
|
|
|
assert_response :unprocessable_entity
|
|
assert_match I18n.t("onchain_wallet_items.change_address.errors.already_linked"), response.body
|
|
end
|
|
|
|
test "change_address moves onto an address whose leftover row tracks nothing" do
|
|
item = create_onchain_wallet_item(family: @family)
|
|
link_onchain_wallet_account!(create_onchain_wallet_account(item: item))
|
|
leftover = create_onchain_wallet_account(item: item, address: OnchainTestHelper::FAKE_ADDRESS_ALT)
|
|
|
|
patch change_address_onchain_wallet_item_url(item), params: {
|
|
chain: OnchainTestHelper::FAKE_CHAIN,
|
|
address: OnchainTestHelper::FAKE_ADDRESS,
|
|
new_address: OnchainTestHelper::FAKE_ADDRESS_ALT
|
|
}
|
|
|
|
# The destination looks taken but is not: the leftover holds the slot in the
|
|
# unique index without tracking anything, and colliding with it would put a
|
|
# database error in front of the user.
|
|
assert_redirected_to manage_onchain_wallet_item_path(item)
|
|
assert_not OnchainWalletAccount.exists?(leftover.id)
|
|
assert_equal OnchainTestHelper::FAKE_ADDRESS_ALT, item.onchain_wallet_accounts.sole.wallet_address
|
|
end
|
|
|
|
test "disconnect_wallet stops tracking every asset at that address only" do
|
|
item = create_onchain_wallet_item(family: @family)
|
|
create_onchain_wallet_account(item: item)
|
|
create_onchain_wallet_account(item: item, asset: fake_token_asset(contract: "0xusdc"))
|
|
kept = create_onchain_wallet_account(item: item, address: OnchainTestHelper::FAKE_ADDRESS_ALT)
|
|
|
|
assert_difference "OnchainWalletAccount.count", -2 do
|
|
delete disconnect_wallet_onchain_wallet_item_url(item), params: {
|
|
chain: OnchainTestHelper::FAKE_CHAIN,
|
|
address: OnchainTestHelper::FAKE_ADDRESS
|
|
}
|
|
end
|
|
|
|
assert OnchainWalletAccount.exists?(kept.id)
|
|
end
|
|
|
|
test "disconnecting keeps the account and its holdings as a manual account" do
|
|
item = create_onchain_wallet_item(family: @family)
|
|
onchain_account = create_onchain_wallet_account(item: item)
|
|
account = link_onchain_wallet_account!(onchain_account)
|
|
security = Onchain::SecurityResolver.resolve(symbol: onchain_account.symbol)
|
|
holding = account.holdings.create!(
|
|
security: security, date: Date.current, qty: 1, price: 1, amount: 1,
|
|
currency: account.currency, account_provider_id: onchain_account.account_provider.id
|
|
)
|
|
|
|
delete disconnect_asset_onchain_wallet_item_url(item), params: { onchain_wallet_account_id: onchain_account.id }
|
|
|
|
assert Account.exists?(account.id)
|
|
assert holding.reload.persisted?
|
|
assert_nil holding.account_provider_id
|
|
assert_not AccountProvider.exists?(provider_type: "OnchainWalletAccount", provider_id: onchain_account.id)
|
|
end
|
|
|
|
test "management actions refuse a wallet this connection does not track" do
|
|
item = create_onchain_wallet_item(family: @family)
|
|
create_onchain_wallet_account(item: item)
|
|
|
|
get review_tokens_onchain_wallet_item_url(item, chain: OnchainTestHelper::FAKE_CHAIN, address: OnchainTestHelper::FAKE_ADDRESS_ALT)
|
|
|
|
assert_redirected_to manage_onchain_wallet_item_path(item)
|
|
assert_equal I18n.t("onchain_wallet_items.errors.wallet_not_found"), flash[:alert]
|
|
end
|
|
|
|
test "management actions cannot reach another family's connection" do
|
|
other_family_item = create_onchain_wallet_item(family: families(:empty))
|
|
create_onchain_wallet_account(item: other_family_item)
|
|
|
|
get manage_onchain_wallet_item_url(other_family_item)
|
|
assert_response :not_found
|
|
|
|
delete disconnect_wallet_onchain_wallet_item_url(other_family_item), params: {
|
|
chain: OnchainTestHelper::FAKE_CHAIN,
|
|
address: OnchainTestHelper::FAKE_ADDRESS
|
|
}
|
|
assert_response :not_found
|
|
assert_equal 1, other_family_item.onchain_wallet_accounts.count
|
|
end
|
|
|
|
test "disconnect_asset cannot reach a row belonging to another connection" do
|
|
item = create_onchain_wallet_item(family: @family)
|
|
other_item = create_onchain_wallet_item(family: families(:empty))
|
|
other_account = create_onchain_wallet_account(item: other_item)
|
|
|
|
delete disconnect_asset_onchain_wallet_item_url(item), params: { onchain_wallet_account_id: other_account.id }
|
|
|
|
assert_response :not_found
|
|
assert OnchainWalletAccount.exists?(other_account.id)
|
|
end
|
|
|
|
private
|
|
def stub_wallet_with_token(address: OnchainTestHelper::FAKE_ADDRESS)
|
|
stub_fake_snapshot(
|
|
address,
|
|
Onchain::Snapshot.new(
|
|
assets: [ fake_native_asset(quantity: 2), fake_token_asset(symbol: "USDC", contract: "0xusdc", quantity: 10) ],
|
|
movements: []
|
|
)
|
|
)
|
|
end
|
|
end
|