Files
sure/test/models/binance_item/importer_test.rb
T
buzzromainandClaude Opus 5 843e91c257 fix(binance): let go of an asset the wallet no longer holds (#3234)
* fix(binance): let go of an asset the wallet no longer holds

Reported: a coin that had been sold stayed in the crypto account, showing up
alongside the ones still held. Two separate causes, both of which had to go.

The holdings processor only ever wrote what Binance returned. Nothing removed
what it stopped returning, and the account page reads one day's rows — so a
coin sold between two syncs kept its place for the rest of the day. Of the nine
provider holdings processors, only CoinstatsAccount's removes anything; this
follows it.

The removal is keyed on what the payload contains rather than on what was
successfully imported. An asset whose price cannot be fetched is skipped, and
deleting on that basis would turn a price outage into a vanished holding.

The importer made it permanent. Every sub-importer swallows its own error and
answers with an empty asset list, so a total outage reached the upsert looking
exactly like an emptied wallet — and the upsert was skipped, leaving the
previous payload in place for the holdings processor to re-import as today's
holdings. An asset already sold came back on every sync, indefinitely. A
complete outage now raises, so the sync fails instead of reporting a successful
import of nothing, and an emptied wallet is written down rather than skipped
whenever there is an account to correct.

A blank payload and a missing one are no longer the same thing: a wallet
nothing has reported on yet keeps its holdings, since removing them would be
deleting on the strength of missing information.

The import failure is also recorded through DebugLogEntry now, so support can
see it against the connection rather than only in the application log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(binance): do not remove what an unavailable source never reported

Review on #3234, two ways the new cleanup could delete live positions.

A source that fails tells us nothing about what it holds, but the importer
wrote only the sources that answered — and the holdings processor removes what
is missing from that list. A transient margin error, or a key without margin
permission, would therefore have deleted every margin position. Their last
known assets are carried instead, and only while the source stays silent: once
it answers, what it says is what stands, including an asset it has stopped
reporting.

Worse, the guard against a total outage could not fire. EarnImporter's two
sub-requests each rescue to nil, so a double failure returned an empty asset
list with no error at all — indistinguishable from an account holding no Earn
positions. With spot, margin and futures all failing, `results.all?` was still
false, the importer wrote an empty wallet, and the cleanup emptied the
portfolio. Earn reports the double failure now, carrying both messages.

Also from review: the account_provider lookup added for the debug entry walked
a lazy has_one per account. It eager-loads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(binance): keep the Earn side that went silent

Review on #3234. The carry-over works per source, and Earn is one source made
of two calls — so a single failing call slipped through it. With flexible
answering and locked failing, the result carried no error, nothing was carried,
and the cleanup removed every locked position. A key without locked permission
would have deleted them on the first sync.

Reporting the whole source as failed would have been wrong the other way: it
would discard the side that did answer, and freeze Earn entirely for anyone
whose key can only read one of the two.

Every asset already keeps its flexible and locked amounts apart, so the side
that went silent is refilled from what it last reported while the working side
stays fresh. Three tests: the silent side keeps its position, an asset held
only on the silent side survives, and a single failure is still not reported as
an outage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(binance): record a partly-read wallet where support can see it

Review on #3234. A source failing on its own returns normally, so the import
never reaches the rescue in BinanceItem#import_latest_binance_data that writes
the debug entry. The sync reported success, and the only trace that part of the
wallet went unread was an application log line — and none at all when there was
nothing to carry.

It goes through DebugLogEntry now, with the sources that were unavailable and
what each of them said, per the repo's provider-sync guidance. The warning
stays, since it names how many assets were carried, which the entry does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(binance): record an Earn endpoint that stopped answering

Review on #3234. One Earn side failing does not fail the import, so the parent
never reaches record_partial_failure — and /settings/debug showed nothing about
an endpoint that had stopped answering, while positions were being carried
precisely because of it.

Same shape as the parent's entry: a provider_sync warning naming the endpoints
that went silent and what each of them said. The per-endpoint errors were
already collected for the double-failure message; they are keyed by endpoint
now so the entry can say which one.

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-28 08:14:42 +02:00

203 lines
7.5 KiB
Ruby

# frozen_string_literal: true
require "test_helper"
class BinanceItem::ImporterTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@item = BinanceItem.create!(family: @family, name: "B", api_key: "k", api_secret: "s")
@provider = mock
@provider.stubs(:get_spot_price).returns("50000.0")
stub_spot_result([ { symbol: "BTC", free: "1.0", locked: "0.0", total: "1.0" } ])
stub_margin_result([])
stub_earn_result([])
stub_futures_result([])
end
test "creates a binance_account of type combined" do
assert_difference "@item.binance_accounts.count", 1 do
BinanceItem::Importer.new(@item, binance_provider: @provider).import
end
ba = @item.binance_accounts.first
assert_equal "combined", ba.account_type
assert_equal "USD", ba.currency
end
test "calculates combined USD balance" do
@provider.stubs(:get_spot_price).with("BTCUSDT").returns("50000.0")
BinanceItem::Importer.new(@item, binance_provider: @provider).import
ba = @item.binance_accounts.first
assert_in_delta 50000.0, ba.current_balance.to_f, 0.01
end
test "stablecoins counted at 1.0 without API call" do
stub_spot_result([ { symbol: "USDT", free: "1000.0", locked: "0.0", total: "1000.0" } ])
@provider.expects(:get_spot_price).never
BinanceItem::Importer.new(@item, binance_provider: @provider).import
ba = @item.binance_accounts.first
assert_in_delta 1000.0, ba.current_balance.to_f, 0.01
end
test "skips BinanceAccount creation when all sources empty" do
stub_spot_result([])
stub_margin_result([])
stub_earn_result([])
stub_futures_result([])
assert_no_difference "@item.binance_accounts.count" do
BinanceItem::Importer.new(@item, binance_provider: @provider).import
end
end
# Each sub-importer swallows its own error and answers with an empty asset
# list, so a total outage reached the upsert looking exactly like an emptied
# wallet. It reported success, left the previous payload in place, and the
# holdings processor re-imported that payload as today's holdings — so an
# asset already sold came back on every sync.
test "a total outage is not an empty wallet" do
stub_failed_result("spot")
stub_failed_result("margin")
stub_failed_result("earn")
stub_failed_result("futures")
assert_raises BinanceItem::Importer::AllRequestsFailed do
BinanceItem::Importer.new(@item, binance_provider: @provider).import
end
end
# A source that failed tells us nothing about what it holds. Writing only the
# sources that answered would hand the holdings processor a list missing every
# margin position, and it removes what is missing — so a transient error, or a
# key without margin permission, would delete live positions.
test "a source that failed keeps its last known assets" do
stub_margin_result([ { symbol: "ETH", free: "2.0", locked: "0.0", total: "2.0" } ])
BinanceItem::Importer.new(@item, binance_provider: @provider).import
assert_equal [ "BTC", "ETH" ], payload_symbols.sort
stub_failed_result("margin")
BinanceItem::Importer.new(@item, binance_provider: @provider).import
assert_equal [ "BTC", "ETH" ], payload_symbols.sort,
"the unavailable source's positions were dropped"
assert_equal "margin", payload_assets.find { |a| a["symbol"] == "ETH" }["source"]
end
# Carried only while the source is silent: once it answers again, what it says
# is what stands, including an asset it no longer reports.
test "a source that answers again overrides what was carried" do
stub_margin_result([ { symbol: "ETH", free: "2.0", locked: "0.0", total: "2.0" } ])
BinanceItem::Importer.new(@item, binance_provider: @provider).import
stub_failed_result("margin")
BinanceItem::Importer.new(@item, binance_provider: @provider).import
assert_includes payload_symbols, "ETH"
stub_margin_result([])
BinanceItem::Importer.new(@item, binance_provider: @provider).import
assert_equal [ "BTC" ], payload_symbols, "the carried asset outlived the source coming back"
end
# A partial failure returns normally, so it never reaches the rescue that logs
# a failed import. Support would otherwise have no record that part of the
# wallet went unread on a sync that reported success.
test "a source that failed is recorded against the connection" do
stub_failed_result("margin")
assert_difference "DebugLogEntry.count", 1 do
BinanceItem::Importer.new(@item, binance_provider: @provider).import
end
entry = DebugLogEntry.order(:created_at).last
assert_equal "binance", entry.provider_key
assert_equal [ "margin" ], entry.metadata["unavailable_sources"]
assert_equal @family, entry.family
end
# One call still answering means the picture is trustworthy, even if it is
# only part of one. Only a complete outage tells us nothing.
test "a partial outage still records what did answer" do
stub_failed_result("margin")
stub_failed_result("earn")
stub_failed_result("futures")
BinanceItem::Importer.new(@item, binance_provider: @provider).import
assert_equal [ "BTC" ], @item.binance_accounts.first.raw_payload["assets"].map { |a| a["symbol"] }
end
# The wallet an existing account was built from can legitimately empty out,
# and that has to be written down: the holdings processor reads this payload,
# so leaving the old one behind kept the sold assets alive indefinitely.
test "emptying a wallet is recorded rather than skipped" do
BinanceItem::Importer.new(@item, binance_provider: @provider).import
assert_equal [ "BTC" ], @item.binance_accounts.first.raw_payload["assets"].map { |a| a["symbol"] }
stub_spot_result([])
BinanceItem::Importer.new(@item, binance_provider: @provider).import
ba = @item.binance_accounts.first.reload
assert_empty ba.raw_payload["assets"], "the emptied wallet kept its old asset list"
assert_equal 0, ba.current_balance.to_d
end
test "stores source breakdown in raw_payload" do
BinanceItem::Importer.new(@item, binance_provider: @provider).import
ba = @item.binance_accounts.first
assert ba.raw_payload.key?("spot")
assert ba.raw_payload.key?("margin")
assert ba.raw_payload.key?("earn")
assert ba.raw_payload.key?("futures")
end
private
def payload_assets
@item.binance_accounts.first.reload.raw_payload["assets"]
end
def payload_symbols
payload_assets.map { |a| a["symbol"] }.uniq
end
def stub_failed_result(source)
klass = { "spot" => BinanceItem::SpotImporter, "margin" => BinanceItem::MarginImporter,
"earn" => BinanceItem::EarnImporter, "futures" => BinanceItem::FuturesImporter }.fetch(source)
klass.any_instance.stubs(:import).returns(
{ assets: [], raw: nil, source: source, error: "boom" }
)
end
def stub_spot_result(assets)
BinanceItem::SpotImporter.any_instance.stubs(:import).returns(
{ assets: assets, raw: {}, source: "spot" }
)
end
def stub_margin_result(assets)
BinanceItem::MarginImporter.any_instance.stubs(:import).returns(
{ assets: assets, raw: {}, source: "margin" }
)
end
def stub_earn_result(assets)
BinanceItem::EarnImporter.any_instance.stubs(:import).returns(
{ assets: assets, raw: {}, source: "earn" }
)
end
def stub_futures_result(assets)
BinanceItem::FuturesImporter.any_instance.stubs(:import).returns(
{ assets: assets, raw: {}, source: "futures" }
)
end
end