mirror of
https://github.com/we-promise/sure.git
synced 2026-04-13 09:07:25 +00:00
* Binance as securities provider * Disable twelve data crypto results * Add logo support and new currency pairs * FIX importer fallback * Add price clamping and optiimize retrieval * Review * Update adding-a-securities-provider.md * day gap miss fix * New fixes * Brandfetch doesn't support crypto. add new CDN * Update _investment_performance.html.erb
224 lines
7.1 KiB
Ruby
224 lines
7.1 KiB
Ruby
require "test_helper"
|
|
|
|
class Provider::TwelveDataTest < ActiveSupport::TestCase
|
|
setup do
|
|
@provider = Provider::TwelveData.new("test_api_key")
|
|
end
|
|
|
|
# ================================
|
|
# Rate Limit Detection Tests
|
|
# ================================
|
|
|
|
test "detects rate limit from JSON body code 429" do
|
|
rate_limit_body = {
|
|
"code" => 429,
|
|
"message" => "You have run out of API credits for the current minute.",
|
|
"status" => "error"
|
|
}.to_json
|
|
|
|
mock_response = mock
|
|
mock_response.stubs(:body).returns(rate_limit_body)
|
|
|
|
@provider.stubs(:throttle_request)
|
|
@provider.stubs(:client).returns(mock_client = mock)
|
|
mock_client.stubs(:get).returns(mock_response)
|
|
|
|
result = @provider.fetch_exchange_rates(from: "USD", to: "EUR", start_date: Date.current, end_date: Date.current)
|
|
|
|
assert_not result.success?
|
|
assert_instance_of Provider::TwelveData::RateLimitError, result.error
|
|
end
|
|
|
|
test "detects rate limit on single exchange rate fetch" do
|
|
rate_limit_body = {
|
|
"code" => 429,
|
|
"message" => "Rate limit exceeded"
|
|
}.to_json
|
|
|
|
mock_response = mock
|
|
mock_response.stubs(:body).returns(rate_limit_body)
|
|
|
|
@provider.stubs(:throttle_request)
|
|
@provider.stubs(:client).returns(mock_client = mock)
|
|
mock_client.stubs(:get).returns(mock_response)
|
|
|
|
result = @provider.fetch_exchange_rate(from: "USD", to: "EUR", date: Date.current)
|
|
|
|
assert_not result.success?
|
|
assert_instance_of Provider::TwelveData::RateLimitError, result.error
|
|
end
|
|
|
|
test "does not fall through to cross API when rate limited" do
|
|
rate_limit_body = {
|
|
"code" => 429,
|
|
"message" => "Rate limit exceeded"
|
|
}.to_json
|
|
|
|
mock_response = mock
|
|
mock_response.stubs(:body).returns(rate_limit_body)
|
|
|
|
@provider.stubs(:throttle_request)
|
|
mock_client = mock
|
|
# Should only be called once (time_series), NOT a second time (time_series/cross)
|
|
mock_client.expects(:get).once.returns(mock_response)
|
|
@provider.stubs(:client).returns(mock_client)
|
|
|
|
result = @provider.fetch_exchange_rates(from: "USD", to: "EUR", start_date: Date.current, end_date: Date.current)
|
|
|
|
assert_not result.success?
|
|
assert_instance_of Provider::TwelveData::RateLimitError, result.error
|
|
end
|
|
|
|
# ================================
|
|
# Error Transformer Tests
|
|
# ================================
|
|
|
|
test "default_error_transformer preserves RateLimitError" do
|
|
error = Provider::TwelveData::RateLimitError.new("Rate limit exceeded")
|
|
|
|
result = @provider.send(:with_provider_response) { raise error }
|
|
|
|
assert_not result.success?
|
|
assert_instance_of Provider::TwelveData::RateLimitError, result.error
|
|
end
|
|
|
|
test "default_error_transformer converts Faraday 429 to RateLimitError" do
|
|
error = Faraday::TooManyRequestsError.new("Too Many Requests", { body: "Rate limited" })
|
|
|
|
result = @provider.send(:with_provider_response) { raise error }
|
|
|
|
assert_not result.success?
|
|
assert_instance_of Provider::TwelveData::RateLimitError, result.error
|
|
end
|
|
|
|
test "default_error_transformer wraps generic errors as Error" do
|
|
error = StandardError.new("Something went wrong")
|
|
|
|
result = @provider.send(:with_provider_response) { raise error }
|
|
|
|
assert_not result.success?
|
|
assert_instance_of Provider::TwelveData::Error, result.error
|
|
end
|
|
|
|
# ================================
|
|
# Crypto Filter Tests
|
|
# ================================
|
|
|
|
test "search_securities excludes Digital Currency rows" do
|
|
body = {
|
|
"data" => [
|
|
{
|
|
"symbol" => "ETH",
|
|
"instrument_name" => "Grayscale Ethereum Trust ETF",
|
|
"mic_code" => "ARCX",
|
|
"instrument_type" => "ETF",
|
|
"country" => "United States",
|
|
"currency" => "USD"
|
|
},
|
|
{
|
|
"symbol" => "ETH/EUR",
|
|
"instrument_name" => "Ethereum Euro",
|
|
"mic_code" => "DIGITAL_CURRENCY",
|
|
"instrument_type" => "Digital Currency",
|
|
"country" => "",
|
|
"currency" => ""
|
|
},
|
|
{
|
|
"symbol" => "BTC/USD",
|
|
"instrument_name" => "Bitcoin US Dollar",
|
|
"mic_code" => "DIGITAL_CURRENCY",
|
|
"instrument_type" => "Digital Currency",
|
|
"country" => "",
|
|
"currency" => ""
|
|
}
|
|
]
|
|
}.to_json
|
|
|
|
mock_response = mock
|
|
mock_response.stubs(:body).returns(body)
|
|
@provider.stubs(:throttle_request)
|
|
@provider.stubs(:client).returns(mock_client = mock)
|
|
mock_client.stubs(:get).returns(mock_response)
|
|
|
|
result = @provider.search_securities("ETH")
|
|
|
|
assert result.success?
|
|
assert_equal 1, result.data.size
|
|
assert_equal "ETH", result.data.first.symbol
|
|
refute result.data.any? { |s| s.symbol.include?("/") }
|
|
end
|
|
|
|
test "search_securities excludes crypto even with mixed-case instrument_type" do
|
|
body = {
|
|
"data" => [
|
|
{
|
|
"symbol" => "BTC/EUR",
|
|
"instrument_name" => "Bitcoin Euro",
|
|
"mic_code" => "",
|
|
"instrument_type" => "digital currency",
|
|
"currency" => ""
|
|
}
|
|
]
|
|
}.to_json
|
|
|
|
mock_response = mock
|
|
mock_response.stubs(:body).returns(body)
|
|
@provider.stubs(:throttle_request)
|
|
@provider.stubs(:client).returns(mock_client = mock)
|
|
mock_client.stubs(:get).returns(mock_response)
|
|
|
|
result = @provider.search_securities("BTC")
|
|
assert_empty result.data
|
|
end
|
|
|
|
# ================================
|
|
# Throttle Tests
|
|
# ================================
|
|
|
|
test "throttle_request enforces minimum interval between calls" do
|
|
@provider.send(:instance_variable_set, :@last_request_time, Time.current)
|
|
|
|
# Stub sleep to capture the call without actually sleeping
|
|
sleep_called_with = nil
|
|
@provider.define_singleton_method(:sleep) { |duration| sleep_called_with = duration }
|
|
|
|
# Stub cache to return under limit (read returns current count, increment charges)
|
|
Rails.cache.stubs(:read).returns(0)
|
|
Rails.cache.stubs(:increment).returns(1)
|
|
|
|
@provider.send(:throttle_request)
|
|
|
|
assert_not_nil sleep_called_with, "Should have called sleep to enforce minimum interval"
|
|
assert_operator sleep_called_with, :>, 0
|
|
end
|
|
|
|
test "throttle_request waits when per-minute credit limit is exceeded" do
|
|
# Stub cache read to return count at limit (adding 1 more would exceed 7)
|
|
Rails.cache.stubs(:read).returns(7)
|
|
Rails.cache.stubs(:increment).returns(8)
|
|
|
|
sleep_called = false
|
|
@provider.define_singleton_method(:sleep) { |_duration| sleep_called = true }
|
|
|
|
@provider.send(:throttle_request)
|
|
|
|
assert sleep_called, "Should have called sleep when credit limit exceeded"
|
|
end
|
|
|
|
test "throttle_request does not wait when under credit limit" do
|
|
# Set last_request_time far in the past so per-instance throttle doesn't trigger
|
|
@provider.send(:instance_variable_set, :@last_request_time, Time.at(0))
|
|
|
|
# Stub cache to return under limit
|
|
Rails.cache.stubs(:read).returns(3)
|
|
Rails.cache.stubs(:increment).returns(4)
|
|
|
|
sleep_called = false
|
|
@provider.define_singleton_method(:sleep) { |_duration| sleep_called = true }
|
|
|
|
@provider.send(:throttle_request)
|
|
|
|
assert_not sleep_called, "Should not sleep when under credit limit"
|
|
end
|
|
end
|