require "test_helper" class Provider::BinancePublicTest < ActiveSupport::TestCase setup do @provider = Provider::BinancePublic.new @provider.stubs(:throttle_request) end # ================================ # Search # ================================ test "search_securities returns one result per supported quote" do @provider.stubs(:exchange_info_symbols).returns(sample_exchange_info) response = @provider.search_securities("BTC") assert response.success? tickers = response.data.map(&:symbol) assert_includes tickers, "BTCUSD" assert_includes tickers, "BTCEUR" assert_includes tickers, "BTCJPY" assert_includes tickers, "BTCBRL" assert_includes tickers, "BTCTRY" refute_includes tickers, "BTCGBP", "GBP has zero Binance pairs and should never surface" end test "search_securities maps USDT pair to USD currency" do @provider.stubs(:exchange_info_symbols).returns(sample_exchange_info) response = @provider.search_securities("BTC") usd_row = response.data.find { |s| s.symbol == "BTCUSD" } assert_equal "USD", usd_row.currency assert_equal "BNCX", usd_row.exchange_operating_mic assert_nil usd_row.country_code, "Crypto is jurisdictionless — country must be nil so non-AE families resolve" assert_equal "BTC", usd_row.name end test "search_securities preserves native EUR pair currency" do @provider.stubs(:exchange_info_symbols).returns(sample_exchange_info) response = @provider.search_securities("BTC") eur_row = response.data.find { |s| s.symbol == "BTCEUR" } assert_equal "EUR", eur_row.currency assert_equal "BNCX", eur_row.exchange_operating_mic end test "search_securities is case insensitive" do @provider.stubs(:exchange_info_symbols).returns(sample_exchange_info) upper = @provider.search_securities("ETH").data lower = @provider.search_securities("eth").data assert_equal upper.map(&:symbol).sort, lower.map(&:symbol).sort end test "search_securities skips unsupported quote assets like BNB" do info = [ info_row("BTC", "USDT"), info_row("BTC", "BNB"), info_row("BTC", "BTC") ] @provider.stubs(:exchange_info_symbols).returns(info) response = @provider.search_securities("BTC") assert_equal [ "BTCUSD" ], response.data.map(&:symbol) end test "search_securities returns empty array when query does not match" do @provider.stubs(:exchange_info_symbols).returns(sample_exchange_info) response = @provider.search_securities("NONEXISTENTCOIN") assert response.success? assert_empty response.data end test "search_securities ranks exact matches first" do info = [ info_row("BTCB", "USDT"), # contains "BTC" info_row("BTC", "USDT"), # exact match info_row("WBTC", "USDT") # contains "BTC" ] @provider.stubs(:exchange_info_symbols).returns(info) tickers = @provider.search_securities("BTC").data.map(&:name) assert_equal "BTC", tickers.first end test "search_securities matches when user types the full display ticker (BTCEUR)" do @provider.stubs(:exchange_info_symbols).returns(sample_exchange_info) response = @provider.search_securities("BTCEUR") assert response.success? tickers = response.data.map(&:symbol) assert_includes tickers, "BTCEUR" # Should NOT return every BTC pair — narrow query, narrow result set. refute_includes tickers, "BTCJPY" refute_includes tickers, "BTCBRL" refute_includes tickers, "BTCTRY" end test "search_securities matches BTCUSD against the raw BTCUSDT pair" do @provider.stubs(:exchange_info_symbols).returns(sample_exchange_info) response = @provider.search_securities("BTCUSD") assert response.success? tickers = response.data.map(&:symbol) # "BTCUSD" is a prefix of Binance's raw "BTCUSDT" — that single USDT-backed # USD variant is what should come back (we store it as BTCUSD for the user). assert_equal [ "BTCUSD" ], tickers end test "search_securities ranks exact symbol match above base prefix match" do info = [ info_row("BTC", "USDT"), # base="BTC", symbol="BTCUSDT" info_row("BTC", "EUR"), # base="BTC", symbol="BTCEUR" <- exact symbol match info_row("BTCB", "EUR") # base="BTCB", symbol="BTCBEUR" ] @provider.stubs(:exchange_info_symbols).returns(info) response = @provider.search_securities("BTCEUR") assert_equal [ "BTCEUR" ], response.data.map(&:symbol) end test "search_securities ignores delisted pairs" do info = [ info_row("BTC", "USDT", status: "TRADING"), info_row("LUNA", "USDT", status: "BREAK") ] # exchange_info_symbols already filters by TRADING status, but double-check # that delisted symbols don't leak through the path that fetches them. @provider.stubs(:exchange_info_symbols).returns(info.select { |s| s["status"] == "TRADING" }) tickers = @provider.search_securities("LUNA").data.map(&:symbol) assert_empty tickers end # ================================ # Ticker parsing # ================================ test "parse_ticker maps USD suffix to USDT pair" do parsed = @provider.send(:parse_ticker, "BTCUSD") assert_equal "BTCUSDT", parsed[:binance_pair] assert_equal "BTC", parsed[:base] assert_equal "USD", parsed[:display_currency] end test "parse_ticker keeps EUR suffix as-is" do parsed = @provider.send(:parse_ticker, "ETHEUR") assert_equal "ETHEUR", parsed[:binance_pair] assert_equal "ETH", parsed[:base] assert_equal "EUR", parsed[:display_currency] end test "parse_ticker returns nil for unsupported suffix" do assert_nil @provider.send(:parse_ticker, "BTCBNB") assert_nil @provider.send(:parse_ticker, "GIBBERISH") end # ================================ # Single price # ================================ test "fetch_security_price returns Price for a single day" do mock_client_returning_klines([ kline_row("2026-01-15", "42000.50") ]) response = @provider.fetch_security_price( symbol: "BTCUSD", exchange_operating_mic: "BNCX", date: Date.parse("2026-01-15") ) assert response.success? assert_equal Date.parse("2026-01-15"), response.data.date assert_in_delta 42000.50, response.data.price assert_equal "USD", response.data.currency assert_equal "BNCX", response.data.exchange_operating_mic end test "fetch_security_price raises InvalidSecurityPriceError for empty response" do mock_client_returning_klines([]) response = @provider.fetch_security_price( symbol: "BTCUSD", exchange_operating_mic: "BNCX", date: Date.parse("2026-01-15") ) assert_not response.success? assert_instance_of Provider::BinancePublic::InvalidSecurityPriceError, response.error end test "fetch_security_price fails for unsupported ticker" do response = @provider.fetch_security_price( symbol: "NOPE", exchange_operating_mic: "BNCX", date: Date.current ) assert_not response.success? assert_instance_of Provider::BinancePublic::InvalidSecurityPriceError, response.error end # ================================ # Historical prices # ================================ test "fetch_security_prices returns rows across a small range" do rows = (0..4).map { |i| kline_row(Date.parse("2026-01-01") + i.days, (40000 + i).to_s) } mock_client_returning_klines(rows) response = @provider.fetch_security_prices( symbol: "BTCUSD", exchange_operating_mic: "BNCX", start_date: Date.parse("2026-01-01"), end_date: Date.parse("2026-01-05") ) assert response.success? assert_equal 5, response.data.size assert_equal Date.parse("2026-01-01"), response.data.first.date assert_equal Date.parse("2026-01-05"), response.data.last.date assert response.data.all? { |p| p.currency == "USD" } end test "fetch_security_prices filters out zero-close rows" do rows = [ kline_row("2026-01-01", "40000"), kline_row("2026-01-02", "0"), kline_row("2026-01-03", "41000") ] mock_client_returning_klines(rows) response = @provider.fetch_security_prices( symbol: "BTCUSD", exchange_operating_mic: "BNCX", start_date: Date.parse("2026-01-01"), end_date: Date.parse("2026-01-03") ) assert_equal 2, response.data.size end test "fetch_security_prices paginates when range exceeds KLINE_MAX_LIMIT" do first_batch = Array.new(1000) { |i| kline_row(Date.parse("2022-01-01") + i.days, "40000") } second_batch = Array.new(200) { |i| kline_row(Date.parse("2024-09-27") + i.days, "42000") } mock_response_1 = mock mock_response_1.stubs(:body).returns(first_batch.to_json) mock_response_2 = mock mock_response_2.stubs(:body).returns(second_batch.to_json) mock_client = mock mock_client.expects(:get).twice.returns(mock_response_1).then.returns(mock_response_2) @provider.stubs(:client).returns(mock_client) response = @provider.fetch_security_prices( symbol: "BTCUSD", exchange_operating_mic: "BNCX", start_date: Date.parse("2022-01-01"), end_date: Date.parse("2025-04-14") ) assert response.success? assert_equal 1200, response.data.size end test "fetch_security_prices does NOT terminate on a short (straddle) batch" do # Regression: a window that straddles the pair's listing date returns # fewer than KLINE_MAX_LIMIT rows but more valid data exists in subsequent # windows. The old `break if batch.size < KLINE_MAX_LIMIT` dropped that # tail. Mock: first call = 638 rows (straddle), second call = 800 rows # (mid-history), third call = 300 rows (final tail). first_batch = Array.new(638) { |i| kline_row(Date.parse("2020-01-03") + i.days, "7000") } second_batch = Array.new(800) { |i| kline_row(Date.parse("2021-10-02") + i.days, "40000") } third_batch = Array.new(300) { |i| kline_row(Date.parse("2024-06-28") + i.days, "62000") } mock_response_1 = mock mock_response_1.stubs(:body).returns(first_batch.to_json) mock_response_2 = mock mock_response_2.stubs(:body).returns(second_batch.to_json) mock_response_3 = mock mock_response_3.stubs(:body).returns(third_batch.to_json) mock_client = mock mock_client.expects(:get).times(3) .returns(mock_response_1).then .returns(mock_response_2).then .returns(mock_response_3) @provider.stubs(:client).returns(mock_client) response = @provider.fetch_security_prices( symbol: "BTCUSD", exchange_operating_mic: "BNCX", start_date: Date.parse("2019-01-05"), end_date: Date.parse("2026-04-10") ) assert response.success? assert_equal 1738, response.data.size end test "fetch_security_prices skips pre-listing empty windows and collects later data" do # Regression for the BTCEUR bug: asking for a range starting before the # pair's listing date used to return zero prices because the first empty # window tripped `break if batch.blank?`. empty_batch = [] real_batch = (0..4).map { |i| kline_row(Date.parse("2020-01-03") + i.days, "6568") } mock_response_empty = mock mock_response_empty.stubs(:body).returns(empty_batch.to_json) mock_response_real = mock mock_response_real.stubs(:body).returns(real_batch.to_json) mock_client = mock mock_client.expects(:get).twice .returns(mock_response_empty).then .returns(mock_response_real) @provider.stubs(:client).returns(mock_client) response = @provider.fetch_security_prices( symbol: "BTCEUR", exchange_operating_mic: "BNCX", start_date: Date.parse("2017-01-01"), end_date: Date.parse("2020-01-07") ) assert response.success? assert_equal 5, response.data.size assert_equal Date.parse("2020-01-03"), response.data.first.date assert response.data.all? { |p| p.currency == "EUR" } end test "fetch_security_prices terminates on empty window once data has been seen" do # Post-delisting / end-of-history scenario: first window returns data, # second window returns empty → stop to avoid wasting calls. first_batch = (0..2).map { |i| kline_row(Date.parse("2017-08-17") + i.days, "4500") } empty_batch = [] mock_response_1 = mock mock_response_1.stubs(:body).returns(first_batch.to_json) mock_response_2 = mock mock_response_2.stubs(:body).returns(empty_batch.to_json) mock_client = mock mock_client.expects(:get).twice .returns(mock_response_1).then .returns(mock_response_2) @provider.stubs(:client).returns(mock_client) response = @provider.fetch_security_prices( symbol: "BTCUSD", exchange_operating_mic: "BNCX", start_date: Date.parse("2017-08-17"), end_date: Date.parse("2024-09-24") ) assert response.success? assert_equal 3, response.data.size end test "fetch_security_prices uses native quote currency for EUR pair" do rows = [ kline_row("2026-01-15", "38000.12") ] mock_client_returning_klines(rows) response = @provider.fetch_security_prices( symbol: "BTCEUR", exchange_operating_mic: "BNCX", start_date: Date.parse("2026-01-15"), end_date: Date.parse("2026-01-15") ) assert_equal "EUR", response.data.first.currency end test "fetch_security_prices returns empty array for unsupported ticker wrapped as error" do response = @provider.fetch_security_prices( symbol: "NOPE", exchange_operating_mic: "BNCX", start_date: Date.current - 5, end_date: Date.current ) assert_not response.success? assert_instance_of Provider::BinancePublic::InvalidSecurityPriceError, response.error end # ================================ # Info # ================================ test "fetch_security_info returns crypto kind and nil logo_url" do response = @provider.fetch_security_info(symbol: "BTCUSD", exchange_operating_mic: "BNCX") assert response.success? assert_equal "BTC", response.data.name assert_equal "crypto", response.data.kind assert_match(/binance\.com/, response.data.links) # logo_url is always nil — crypto logos are resolved at render time via # Security#display_logo_url using the Brandfetch probe verdict, so the # provider has nothing sensible to persist here. assert_nil response.data.logo_url end # ================================ # Quote currency coverage # ================================ test "parse_ticker rejects GBP (unsupported)" do assert_nil @provider.send(:parse_ticker, "BTCGBP") end test "parse_ticker maps JPY pair" do parsed = @provider.send(:parse_ticker, "BTCJPY") assert_equal "BTCJPY", parsed[:binance_pair] assert_equal "BTC", parsed[:base] assert_equal "JPY", parsed[:display_currency] end test "parse_ticker maps BRL pair" do parsed = @provider.send(:parse_ticker, "ETHBRL") assert_equal "ETHBRL", parsed[:binance_pair] assert_equal "ETH", parsed[:base] assert_equal "BRL", parsed[:display_currency] end test "fetch_security_prices returns JPY currency for a BTCJPY range" do rows = [ kline_row("2026-01-15", "10800000") ] mock_client_returning_klines(rows) response = @provider.fetch_security_prices( symbol: "BTCJPY", exchange_operating_mic: "BNCX", start_date: Date.parse("2026-01-15"), end_date: Date.parse("2026-01-15") ) assert_equal "JPY", response.data.first.currency assert_in_delta 10_800_000.0, response.data.first.price end test "fetch_security_prices returns BRL currency for a BTCBRL range" do rows = [ kline_row("2026-01-15", "350000") ] mock_client_returning_klines(rows) response = @provider.fetch_security_prices( symbol: "BTCBRL", exchange_operating_mic: "BNCX", start_date: Date.parse("2026-01-15"), end_date: Date.parse("2026-01-15") ) assert_equal "BRL", response.data.first.currency end # ================================ # Logo URL plumbing # ================================ test "search_securities populates each result with the Brandfetch crypto URL" do @provider.stubs(:exchange_info_symbols).returns(sample_exchange_info) Setting.stubs(:brand_fetch_client_id).returns("test-client-id") Setting.stubs(:brand_fetch_logo_size).returns(120) response = @provider.search_securities("BTC") expected = "https://cdn.brandfetch.io/crypto/BTC/icon/fallback/lettermark/w/120/h/120?c=test-client-id" assert response.data.all? { |s| s.logo_url == expected } end test "search_securities leaves logo_url nil when Brandfetch is not configured" do @provider.stubs(:exchange_info_symbols).returns(sample_exchange_info) Setting.stubs(:brand_fetch_client_id).returns(nil) response = @provider.search_securities("BTC") assert response.data.all? { |s| s.logo_url.nil? } end # ================================ # Helpers # ================================ private def sample_exchange_info [ info_row("BTC", "USDT"), info_row("BTC", "EUR"), info_row("BTC", "JPY"), info_row("BTC", "BRL"), info_row("BTC", "TRY"), info_row("ETH", "USDT"), info_row("ETH", "EUR"), info_row("ETH", "JPY"), info_row("SOL", "USDT"), info_row("BNB", "USDT") ] end def info_row(base, quote, status: "TRADING") { "symbol" => "#{base}#{quote}", "baseAsset" => base, "quoteAsset" => quote, "status" => status } end # Mimics Binance /api/v3/klines row format. # Index 0 = open time (ms), index 4 = close price (string) def kline_row(date, close) date = Date.parse(date) if date.is_a?(String) open_time_ms = Time.utc(date.year, date.month, date.day).to_i * 1000 [ open_time_ms, # 0: Open time "0", # 1: Open "0", # 2: High "0", # 3: Low close.to_s, # 4: Close "0", # 5: Volume open_time_ms + (24 * 60 * 60 * 1000 - 1), # 6: Close time "0", 0, "0", "0", "0" ] end def mock_client_returning_klines(rows) mock_response = mock mock_response.stubs(:body).returns(rows.to_json) mock_client = mock mock_client.stubs(:get).returns(mock_response) @provider.stubs(:client).returns(mock_client) end # Rails.cache in the test env is a NullStore by default, so Rails.cache.fetch # re-runs the block every time. Swap in a real MemoryStore so cache-hit # assertions are meaningful, then restore the original. def with_memory_cache original = Rails.cache Rails.cache = ActiveSupport::Cache::MemoryStore.new yield ensure Rails.cache = original end end