mirror of
https://github.com/we-promise/sure.git
synced 2026-09-07 15:44:21 +00:00
Add JKT → XIDX exchange MIC mapping, .JK symbol suffix normalization, IDR default currency, and ID country code for Jakarta exchange. Yahoo Finance returns Indonesian stocks (e.g. BBCA.JK) with exchange code 'JKT'. Without this mapping, the provider cannot resolve the exchange to the XIDX MIC already defined in config/exchanges.yml, and normalize_symbol cannot append the .JK suffix for price lookups. Tested manually: Yahoo Finance search and chart endpoints return valid results for IDX tickers (BBCA.JK, currency=IDR, timezone=WIB).
964 lines
36 KiB
Ruby
964 lines
36 KiB
Ruby
require "test_helper"
|
|
|
|
class Provider::YahooFinanceTest < ActiveSupport::TestCase
|
|
include ActiveJob::TestHelper
|
|
|
|
setup do
|
|
@provider = Provider::YahooFinance.new
|
|
@cache = ActiveSupport::Cache::MemoryStore.new
|
|
Rails.stubs(:cache).returns(@cache)
|
|
DebugLogEntry.stubs(:capture)
|
|
end
|
|
|
|
# ================================
|
|
# Health Check Tests
|
|
# ================================
|
|
|
|
test "health_status returns cached status and schedules a stale refresh" do
|
|
@cache.write(
|
|
Provider::YahooFinance::HEALTH_STATUS_CACHE_KEY,
|
|
{ status: :healthy, checked_at: 16.minutes.ago },
|
|
expires_in: Provider::YahooFinance::HEALTH_STATUS_RETENTION
|
|
)
|
|
@provider.expects(:perform_health_check).never
|
|
|
|
assert_enqueued_with(job: YahooFinanceHealthCheckJob) do
|
|
assert_equal :healthy, @provider.health_status
|
|
end
|
|
end
|
|
|
|
test "health_status returns unknown and schedules a cold refresh" do
|
|
@provider.expects(:perform_health_check).never
|
|
|
|
assert_enqueued_with(job: YahooFinanceHealthCheckJob) do
|
|
assert_equal :unknown, @provider.health_status
|
|
end
|
|
end
|
|
|
|
test "health_status does not schedule a refresh for fresh evidence" do
|
|
@cache.write(
|
|
Provider::YahooFinance::HEALTH_STATUS_CACHE_KEY,
|
|
{ status: :healthy, checked_at: Time.current },
|
|
expires_in: Provider::YahooFinance::HEALTH_STATUS_RETENTION
|
|
)
|
|
|
|
assert_no_enqueued_jobs only: YahooFinanceHealthCheckJob do
|
|
assert_equal :healthy, @provider.health_status
|
|
end
|
|
end
|
|
|
|
test "health_status caches a healthy provider assessment" do
|
|
stub_successful_health_authentication
|
|
stub_health_chart_responses(healthy_chart_response)
|
|
|
|
assert_equal :healthy, @provider.refresh_health_status
|
|
assert_equal :healthy, @provider.refresh_health_status
|
|
end
|
|
|
|
test "health_status classifies a crumb HTTP 429 without retrying" do
|
|
cookie_response = health_response(
|
|
status: 404,
|
|
headers: { "set-cookie" => "A3=test-cookie; Max-Age=3600" }
|
|
)
|
|
crumb_response = health_response(status: 429, body: "secret response body")
|
|
auth_client = mock
|
|
auth_client.expects(:get).twice.returns(cookie_response, crumb_response)
|
|
@provider.stubs(:health_auth_client).returns(auth_client)
|
|
@provider.expects(:health_authenticated_client).never
|
|
|
|
assert_equal :rate_limited, @provider.refresh_health_status
|
|
end
|
|
|
|
test "health_status classifies a cookie HTTP 429 without continuing crumb acquisition" do
|
|
auth_client = mock
|
|
auth_client.expects(:get).once.returns(health_response(status: 429, body: "secret response body"))
|
|
@provider.stubs(:health_auth_client).returns(auth_client)
|
|
@provider.expects(:health_authenticated_client).never
|
|
|
|
assert_equal :rate_limited, @provider.refresh_health_status
|
|
end
|
|
|
|
test "health_status classifies a chart HTTP 429 without retrying" do
|
|
stub_successful_health_authentication
|
|
stub_health_chart_responses(health_response(status: 429, body: "secret response body"))
|
|
|
|
assert_equal :rate_limited, @provider.refresh_health_status
|
|
end
|
|
|
|
test "health_status classifies connection failures and timeouts as unavailable" do
|
|
[ Faraday::ConnectionFailed.new("connection failed"), Faraday::TimeoutError.new("timed out") ].each do |error|
|
|
provider = Provider::YahooFinance.new
|
|
@cache.clear
|
|
auth_client = mock
|
|
auth_client.expects(:get).once.raises(error)
|
|
provider.stubs(:health_auth_client).returns(auth_client)
|
|
|
|
assert_equal :unavailable, provider.refresh_health_status
|
|
end
|
|
end
|
|
|
|
test "health_status clears Unauthorized credentials without retrying immediately" do
|
|
auth_client = mock
|
|
auth_client.expects(:get).times(4).returns(
|
|
cookie_health_response("cookie-1"), health_response(status: 200, body: "crumb-1"),
|
|
cookie_health_response("cookie-2"), health_response(status: 200, body: "crumb-2")
|
|
)
|
|
@provider.stubs(:health_auth_client).returns(auth_client)
|
|
stub_health_chart_responses(
|
|
health_response(status: 200, body: '{"chart":{"error":{"code":"Unauthorized"}}}'),
|
|
healthy_chart_response
|
|
)
|
|
|
|
travel_to Time.zone.parse("2026-07-20 12:00:00") do
|
|
assert_equal :unavailable, @provider.refresh_health_status
|
|
travel 5.minutes + 1.second
|
|
assert_equal :healthy, @provider.refresh_health_status
|
|
end
|
|
end
|
|
|
|
test "health_status classifies malformed and empty chart responses as unavailable" do
|
|
stub_successful_health_authentication
|
|
stub_health_chart_responses(
|
|
health_response(status: 200, body: "not-json"),
|
|
health_response(status: 200, body: '{"chart":{"result":[]}}')
|
|
)
|
|
|
|
travel_to Time.zone.parse("2026-07-20 12:00:00") do
|
|
assert_equal :unavailable, @provider.refresh_health_status
|
|
travel 5.minutes + 1.second
|
|
assert_equal :unavailable, @provider.refresh_health_status
|
|
end
|
|
end
|
|
|
|
test "health_status applies status-specific freshness windows" do
|
|
[
|
|
[ healthy_chart_response, 15.minutes, :healthy ],
|
|
[ health_response(status: 429), 30.minutes, :rate_limited ],
|
|
[ health_response(status: 503), 5.minutes, :unavailable ]
|
|
].each do |first_response, freshness, first_status|
|
|
provider = Provider::YahooFinance.new
|
|
@cache.clear
|
|
stub_successful_health_authentication(provider: provider)
|
|
stub_health_chart_responses(first_response, healthy_chart_response, provider: provider)
|
|
|
|
travel_to Time.zone.parse("2026-07-20 12:00:00") do
|
|
assert_equal first_status, provider.refresh_health_status
|
|
travel freshness - 1.second
|
|
assert_equal first_status, provider.refresh_health_status
|
|
travel 2.seconds
|
|
assert_equal :healthy, provider.refresh_health_status
|
|
end
|
|
end
|
|
end
|
|
|
|
test "health_status returns stale evidence while one refresh is in progress" do
|
|
stub_successful_health_authentication
|
|
started = Queue.new
|
|
finish = Queue.new
|
|
calls = 0
|
|
chart_client = Object.new
|
|
chart_client.define_singleton_method(:get) do |*_args|
|
|
calls += 1
|
|
next healthy_chart_response if calls == 1
|
|
|
|
started << true
|
|
finish.pop
|
|
health_response(status: 503)
|
|
end
|
|
chart_client.define_singleton_method(:healthy_chart_response) { health_response(status: 200, body: '{"chart":{"result":[{}]}}') }
|
|
chart_client.define_singleton_method(:health_response) do |status:, body: ""|
|
|
OpenStruct.new(status: status, body: body, headers: {}, success?: status.between?(200, 299))
|
|
end
|
|
@provider.stubs(:health_authenticated_client).returns(chart_client)
|
|
|
|
travel_to Time.zone.parse("2026-07-20 12:00:00") do
|
|
assert_equal :healthy, @provider.refresh_health_status
|
|
travel 15.minutes + 1.second
|
|
|
|
refreshing = Thread.new { @provider.refresh_health_status }
|
|
started.pop
|
|
assert_equal :healthy, Provider::YahooFinance.new.health_status
|
|
finish << true
|
|
assert_equal :unavailable, refreshing.value
|
|
end
|
|
end
|
|
|
|
test "health_status returns unknown during a cold concurrent refresh" do
|
|
stub_successful_health_authentication
|
|
started = Queue.new
|
|
finish = Queue.new
|
|
chart_client = Object.new
|
|
response = healthy_chart_response
|
|
chart_client.define_singleton_method(:get) do |*_args|
|
|
started << true
|
|
finish.pop
|
|
response
|
|
end
|
|
@provider.stubs(:health_authenticated_client).returns(chart_client)
|
|
|
|
refreshing = Thread.new { @provider.refresh_health_status }
|
|
started.pop
|
|
assert_equal :unknown, Provider::YahooFinance.new.health_status
|
|
finish << true
|
|
assert_equal :healthy, refreshing.value
|
|
end
|
|
|
|
test "health_status discards stale evidence after one hour" do
|
|
stub_successful_health_authentication(count: 2)
|
|
started = Queue.new
|
|
finish = Queue.new
|
|
calls = 0
|
|
first_response = healthy_chart_response
|
|
refresh_response = healthy_chart_response
|
|
chart_client = Object.new
|
|
chart_client.define_singleton_method(:get) do |*_args|
|
|
calls += 1
|
|
next first_response if calls == 1
|
|
|
|
started << true
|
|
finish.pop
|
|
refresh_response
|
|
end
|
|
@provider.stubs(:health_authenticated_client).returns(chart_client)
|
|
|
|
travel_to Time.zone.parse("2026-07-20 12:00:00") do
|
|
assert_equal :healthy, @provider.refresh_health_status
|
|
travel 1.hour + 1.second
|
|
|
|
refreshing = Thread.new { @provider.refresh_health_status }
|
|
started.pop
|
|
assert_equal :unknown, Provider::YahooFinance.new.health_status
|
|
finish << true
|
|
assert_equal :healthy, refreshing.value
|
|
end
|
|
end
|
|
|
|
test "health_status lock expires and an obsolete refresh cannot overwrite newer evidence" do
|
|
stub_successful_health_authentication
|
|
started = Queue.new
|
|
finish = Queue.new
|
|
obsolete_response = health_response(status: 503)
|
|
blocking_client = Object.new
|
|
blocking_client.define_singleton_method(:get) do |*_args|
|
|
started << true
|
|
finish.pop
|
|
obsolete_response
|
|
end
|
|
@provider.stubs(:health_authenticated_client).returns(blocking_client)
|
|
|
|
first_refresh = Thread.new { @provider.refresh_health_status }
|
|
started.pop
|
|
travel 15.seconds + 1.second do
|
|
replacement = Provider::YahooFinance.new
|
|
stub_health_chart_responses(healthy_chart_response, provider: replacement)
|
|
assert_equal :healthy, replacement.refresh_health_status
|
|
end
|
|
finish << true
|
|
|
|
assert_equal :healthy, first_refresh.value
|
|
assert_equal :healthy, Provider::YahooFinance.new.health_status
|
|
end
|
|
|
|
test "health_status returns unknown without contacting Yahoo when cache access fails" do
|
|
failing_cache = mock
|
|
failing_cache.expects(:read).once.raises(Redis::BaseError.new("cache details"))
|
|
Rails.stubs(:cache).returns(failing_cache)
|
|
@provider.expects(:perform_health_check).never
|
|
DebugLogEntry.expects(:capture).with do |attributes|
|
|
attributes[:category] == "provider_health_cache" &&
|
|
attributes[:metadata] == { exception_class: "Redis::BaseError" } &&
|
|
attributes.to_s.exclude?("cache details")
|
|
end
|
|
|
|
assert_equal :unknown, @provider.health_status
|
|
end
|
|
|
|
test "health_status reports a credential-cache failure as unknown" do
|
|
backing_cache = @cache
|
|
read_count = 0
|
|
failing_cache = Object.new
|
|
failing_cache.define_singleton_method(:read) do |key|
|
|
read_count += 1
|
|
raise Redis::BaseError, "cache details" if read_count == 3
|
|
|
|
backing_cache.read(key)
|
|
end
|
|
failing_cache.define_singleton_method(:write) { |key, value, **options| backing_cache.write(key, value, **options) }
|
|
failing_cache.define_singleton_method(:delete) { |key| backing_cache.delete(key) }
|
|
Rails.stubs(:cache).returns(failing_cache)
|
|
@provider.expects(:health_auth_client).never
|
|
|
|
diagnostics = []
|
|
DebugLogEntry.stubs(:capture).with { |attributes| diagnostics << attributes }
|
|
|
|
assert_equal :unknown, @provider.refresh_health_status
|
|
assert_equal [ "provider_health_cache" ], diagnostics.map { |entry| entry[:category] }
|
|
assert diagnostics.none? { |entry| entry.to_s.include?("cache details") }
|
|
end
|
|
|
|
test "health_status records only safe state transitions" do
|
|
stub_successful_health_authentication
|
|
stub_health_chart_responses(health_response(status: 429, body: "secret-body"))
|
|
DebugLogEntry.expects(:capture).with do |attributes|
|
|
attributes[:category] == "provider_health" &&
|
|
attributes[:level] == "warn" &&
|
|
attributes[:metadata] == {
|
|
previous_state: :unknown,
|
|
new_state: :rate_limited,
|
|
health_check_stage: :chart,
|
|
http_status: 429
|
|
} &&
|
|
attributes.to_s.exclude?("secret-body")
|
|
end
|
|
|
|
assert_equal :rate_limited, @provider.refresh_health_status
|
|
end
|
|
|
|
test "health_status omits repeated diagnostics and records healthy recovery" do
|
|
stub_successful_health_authentication(count: 2)
|
|
stub_health_chart_responses(
|
|
health_response(status: 429),
|
|
health_response(status: 429),
|
|
healthy_chart_response
|
|
)
|
|
events = []
|
|
DebugLogEntry.stubs(:capture).with { |attributes| events << attributes }
|
|
|
|
travel_to Time.zone.parse("2026-07-20 12:00:00") do
|
|
assert_equal :rate_limited, @provider.refresh_health_status
|
|
travel 30.minutes + 1.second
|
|
assert_equal :rate_limited, @provider.refresh_health_status
|
|
travel 30.minutes + 1.second
|
|
assert_equal :healthy, @provider.refresh_health_status
|
|
end
|
|
|
|
assert_equal %i[rate_limited healthy], events.map { |event| event.dig(:metadata, :new_state) }
|
|
assert_equal %w[warn info], events.map { |event| event[:level] }
|
|
end
|
|
|
|
test "healthy? is true only for healthy" do
|
|
%i[healthy rate_limited unavailable unknown].each do |status|
|
|
provider = Provider::YahooFinance.new
|
|
provider.stubs(:health_status).returns(status)
|
|
|
|
assert_equal status == :healthy, provider.healthy?
|
|
end
|
|
end
|
|
|
|
test "rate-limited health status does not gate or change normal price and exchange-rate operations" do
|
|
stub_successful_health_authentication
|
|
stub_health_chart_responses(health_response(status: 429))
|
|
assert_equal :rate_limited, @provider.refresh_health_status
|
|
|
|
@provider.stubs(:fetch_authenticated_chart).returns(
|
|
"chart" => {
|
|
"result" => [ {
|
|
"meta" => { "currency" => "USD", "exchangeName" => "NMS" },
|
|
"timestamp" => [ Time.utc(2026, 7, 17).to_i ],
|
|
"indicators" => { "quote" => [ { "close" => [ 210.25 ] } ] }
|
|
} ]
|
|
}
|
|
)
|
|
@provider.stubs(:throttle_request)
|
|
|
|
price_response = @provider.fetch_security_prices(
|
|
symbol: "AAPL",
|
|
exchange_operating_mic: "XNAS",
|
|
start_date: Date.new(2026, 7, 17),
|
|
end_date: Date.new(2026, 7, 17)
|
|
)
|
|
exchange_rate_response = @provider.fetch_exchange_rates(
|
|
from: "USD",
|
|
to: "EUR",
|
|
start_date: Date.new(2026, 7, 17),
|
|
end_date: Date.new(2026, 7, 17)
|
|
)
|
|
|
|
assert price_response.success?
|
|
assert exchange_rate_response.success?
|
|
assert_equal :rate_limited, @provider.refresh_health_status
|
|
end
|
|
|
|
# ================================
|
|
# Exchange Rate Tests
|
|
# ================================
|
|
|
|
test "fetch_exchange_rate returns 1.0 for same currency" do
|
|
date = Date.parse("2024-01-15")
|
|
response = @provider.fetch_exchange_rate(from: "USD", to: "USD", date: date)
|
|
|
|
assert response.success?
|
|
rate = response.data
|
|
assert_equal 1.0, rate.rate
|
|
assert_equal "USD", rate.from
|
|
assert_equal "USD", rate.to
|
|
assert_equal date, rate.date
|
|
end
|
|
|
|
test "fetch_exchange_rate handles invalid currency codes" do
|
|
date = Date.parse("2024-01-15")
|
|
|
|
# With validation removed, invalid currencies will result in API errors
|
|
response = @provider.fetch_exchange_rate(from: "INVALID", to: "USD", date: date)
|
|
assert_not response.success?
|
|
assert_instance_of Provider::YahooFinance::Error, response.error
|
|
|
|
response = @provider.fetch_exchange_rate(from: "USD", to: "INVALID", date: date)
|
|
assert_not response.success?
|
|
assert_instance_of Provider::YahooFinance::Error, response.error
|
|
|
|
response = @provider.fetch_exchange_rate(from: "", to: "USD", date: date)
|
|
assert_not response.success?
|
|
assert_instance_of Provider::YahooFinance::Error, response.error
|
|
end
|
|
|
|
test "fetch_exchange_rates returns same currency rates" do
|
|
start_date = Date.parse("2024-01-10")
|
|
end_date = Date.parse("2024-01-12")
|
|
response = @provider.fetch_exchange_rates(from: "USD", to: "USD", start_date: start_date, end_date: end_date)
|
|
|
|
assert response.success?
|
|
rates = response.data
|
|
expected_dates = (start_date..end_date).to_a
|
|
assert_equal expected_dates.length, rates.length
|
|
assert rates.all? { |r| r.rate == 1.0 }
|
|
assert rates.all? { |r| r.from == "USD" }
|
|
assert rates.all? { |r| r.to == "USD" }
|
|
end
|
|
|
|
test "fetch_exchange_rates validates date range" do
|
|
response = @provider.fetch_exchange_rates(from: "USD", to: "EUR", start_date: Date.current, end_date: Date.current - 1.day)
|
|
assert_not response.success?
|
|
assert_instance_of Provider::YahooFinance::Error, response.error
|
|
|
|
response = @provider.fetch_exchange_rates(from: "USD", to: "EUR", start_date: Date.current - 6.years, end_date: Date.current)
|
|
assert_not response.success?
|
|
assert_instance_of Provider::YahooFinance::Error, response.error
|
|
end
|
|
|
|
# ================================
|
|
# Security Search Tests
|
|
# ================================
|
|
|
|
test "search_securities handles invalid symbols" do
|
|
# With validation removed, invalid symbols will result in API errors
|
|
response = @provider.search_securities("")
|
|
assert_not response.success?
|
|
assert_instance_of Provider::YahooFinance::Error, response.error
|
|
|
|
response = @provider.search_securities("VERYLONGSYMBOLNAME")
|
|
assert_not response.success?
|
|
assert_instance_of Provider::YahooFinance::Error, response.error
|
|
|
|
response = @provider.search_securities("INVALID@SYMBOL")
|
|
assert_not response.success?
|
|
assert_instance_of Provider::YahooFinance::Error, response.error
|
|
end
|
|
|
|
test "search_securities returns empty array for no results with short symbol" do
|
|
# Mock empty results response
|
|
mock_response = mock
|
|
mock_response.stubs(:body).returns('{"quotes":[]}')
|
|
|
|
@provider.stubs(:client).returns(mock_client = mock)
|
|
mock_client.stubs(:get).returns(mock_response)
|
|
|
|
response = @provider.search_securities("XYZ")
|
|
assert response.success?
|
|
assert_equal [], response.data
|
|
end
|
|
|
|
test "search_securities returns canonical Colombian identity for Yahoo BVC listings" do
|
|
mock_response = mock
|
|
mock_response.stubs(:body).returns({
|
|
quotes: [ {
|
|
symbol: "CIBEST.CL",
|
|
longname: "Grupo Cibest S.A.",
|
|
exchange: "BVC",
|
|
exchDisp: "Colombia"
|
|
} ]
|
|
}.to_json)
|
|
|
|
@provider.stubs(:client).returns(mock_client = mock)
|
|
mock_client.stubs(:get).returns(mock_response)
|
|
|
|
response = @provider.search_securities("CIBEST.CL")
|
|
|
|
assert response.success?
|
|
security = response.data.sole
|
|
assert_equal "CIBEST.CL", security.symbol
|
|
assert_equal "XBOG", security.exchange_operating_mic
|
|
assert_equal "CO", security.country_code
|
|
end
|
|
|
|
test "search_securities preserves catalog countries and display-name fallback" do
|
|
mock_response = mock
|
|
mock_response.stubs(:body).returns({
|
|
quotes: [
|
|
{ symbol: "AAPL", shortname: "Apple", exchange: "NMS", exchDisp: "NASDAQ" },
|
|
{ symbol: "SAP.F", shortname: "SAP", exchange: "FRA", exchDisp: "Frankfurt" },
|
|
{ symbol: "FALLBACK", shortname: "Fallback", exchange: "UNKNOWN", exchDisp: "Frankfurt" }
|
|
]
|
|
}.to_json)
|
|
|
|
@provider.stubs(:client).returns(mock_client = mock)
|
|
mock_client.stubs(:get).returns(mock_response)
|
|
|
|
response = @provider.search_securities("company")
|
|
|
|
assert response.success?
|
|
results_by_symbol = response.data.index_by(&:symbol)
|
|
assert_equal "US", results_by_symbol.fetch("AAPL").country_code
|
|
assert_equal "DE", results_by_symbol.fetch("SAP.F").country_code
|
|
assert_equal "DE", results_by_symbol.fetch("FALLBACK").country_code
|
|
end
|
|
|
|
# ================================
|
|
# Security Price Tests
|
|
# ================================
|
|
|
|
test "fetch_security_price handles invalid symbol" do
|
|
date = Date.parse("2024-01-15")
|
|
|
|
# With validation removed, invalid symbols will result in API errors
|
|
response = @provider.fetch_security_price(symbol: "", exchange_operating_mic: "XNAS", date: date)
|
|
assert_not response.success?
|
|
assert_instance_of Provider::YahooFinance::Error, response.error
|
|
end
|
|
|
|
test "fetch_security_prices uses the Colombian Yahoo suffix once and COP fallback" do
|
|
date = Date.new(2024, 1, 15)
|
|
chart_response = mock
|
|
chart_response.stubs(:body).returns({
|
|
chart: {
|
|
result: [ {
|
|
meta: { exchangeName: "BVC" },
|
|
timestamp: [ Time.utc(2024, 1, 15).to_i ],
|
|
indicators: { quote: [ { close: [ 42_350.0 ] } ] }
|
|
} ]
|
|
}
|
|
}.to_json)
|
|
chart_client = mock
|
|
chart_client.expects(:get).with(regexp_matches(%r{/v8/finance/chart/CIBEST\.CL$})).twice.returns(chart_response)
|
|
@provider.stubs(:fetch_cookie_and_crumb).returns([ "cookie", "crumb" ])
|
|
@provider.stubs(:authenticated_client).with("cookie").returns(chart_client)
|
|
@provider.stubs(:throttle_request)
|
|
|
|
responses = [ "CIBEST", "CIBEST.CL" ].map do |symbol|
|
|
@provider.fetch_security_prices(
|
|
symbol: symbol,
|
|
exchange_operating_mic: "XBOG",
|
|
start_date: date,
|
|
end_date: date
|
|
)
|
|
end
|
|
|
|
responses.each do |response|
|
|
assert response.success?
|
|
price = response.data.sole
|
|
assert_equal "CIBEST.CL", price.symbol
|
|
assert_equal "COP", price.currency
|
|
assert_equal "XBOG", price.exchange_operating_mic
|
|
end
|
|
end
|
|
|
|
# ================================
|
|
# Caching Tests
|
|
# ================================
|
|
|
|
# Note: Caching tests are skipped as Rails.cache may not be properly configured in test environment
|
|
# and caching functionality is not the focus of the validation fixes
|
|
|
|
# ================================
|
|
# Error Handling Tests
|
|
# ================================
|
|
|
|
test "handles Faraday errors gracefully" do
|
|
# Mock a Faraday error
|
|
faraday_error = Faraday::ConnectionFailed.new("Connection failed")
|
|
|
|
@provider.stub :client, ->(*) { raise faraday_error } do
|
|
result = @provider.send(:with_provider_response) { raise faraday_error }
|
|
|
|
assert_not result.success?
|
|
assert_instance_of Provider::YahooFinance::Error, result.error
|
|
end
|
|
end
|
|
|
|
test "handles rate limit errors" do
|
|
rate_limit_error = Faraday::TooManyRequestsError.new("Rate limit exceeded", { body: "Too many requests" })
|
|
|
|
@provider.stub :client, ->(*) { raise rate_limit_error } do
|
|
result = @provider.send(:with_provider_response) { raise rate_limit_error }
|
|
|
|
assert_not result.success?
|
|
assert_instance_of Provider::YahooFinance::RateLimitError, result.error
|
|
end
|
|
end
|
|
|
|
test "classifies a successful crumb response containing a rate limit body" do
|
|
cookie_response = mock
|
|
cookie_response.stubs(:headers).returns({ "set-cookie" => "A3=test-cookie; Max-Age=3600" })
|
|
|
|
crumb_response = mock
|
|
crumb_response.stubs(:status).returns(200)
|
|
crumb_response.stubs(:body).returns("Too Many Requests")
|
|
crumb_response.stubs(:success?).returns(true)
|
|
|
|
auth_client = mock
|
|
auth_client.stubs(:get).returns(cookie_response, crumb_response)
|
|
@provider.stubs(:auth_client).returns(auth_client)
|
|
@provider.stubs(:throttle_request)
|
|
|
|
result = @provider.fetch_security_info(symbol: "AAPL", exchange_operating_mic: "XNAS")
|
|
|
|
assert_not result.success?
|
|
assert_instance_of Provider::YahooFinance::RateLimitError, result.error
|
|
end
|
|
|
|
test "handles 401 unauthorized as authentication error" do
|
|
unauthorized_error = Faraday::UnauthorizedError.new("Unauthorized", { body: "Invalid Crumb" })
|
|
|
|
@provider.stub :client, ->(*) { raise unauthorized_error } do
|
|
result = @provider.send(:with_provider_response) { raise unauthorized_error }
|
|
|
|
assert_not result.success?
|
|
assert_instance_of Provider::YahooFinance::AuthenticationError, result.error
|
|
assert_match(/authentication failed/, result.error.message)
|
|
end
|
|
end
|
|
|
|
# ================================
|
|
# User-Agent Rotation Tests
|
|
# ================================
|
|
|
|
test "random_user_agent returns value from USER_AGENTS pool" do
|
|
user_agent = @provider.send(:random_user_agent)
|
|
assert_includes Provider::YahooFinance::USER_AGENTS, user_agent
|
|
end
|
|
|
|
test "USER_AGENTS contains multiple modern browser user-agents" do
|
|
assert Provider::YahooFinance::USER_AGENTS.length >= 5
|
|
assert Provider::YahooFinance::USER_AGENTS.all? { |ua| ua.include?("Mozilla") }
|
|
end
|
|
|
|
# ================================
|
|
# Throttling Tests
|
|
# ================================
|
|
|
|
test "throttle_request enforces minimum interval between requests" do
|
|
# First request should not wait
|
|
start_time = Time.current
|
|
@provider.send(:throttle_request)
|
|
first_elapsed = Time.current - start_time
|
|
assert first_elapsed < 0.1, "First request should not wait"
|
|
|
|
# Second request should wait approximately min_request_interval
|
|
start_time = Time.current
|
|
@provider.send(:throttle_request)
|
|
second_elapsed = Time.current - start_time
|
|
min_interval = @provider.send(:min_request_interval)
|
|
assert second_elapsed >= (min_interval - 0.05), "Second request should wait at least #{min_interval - 0.05}s"
|
|
end
|
|
|
|
# ================================
|
|
# Configuration Tests
|
|
# ================================
|
|
|
|
test "max_retries returns default value" do
|
|
assert_equal 5, @provider.send(:max_retries)
|
|
end
|
|
|
|
test "retry_interval returns default value" do
|
|
assert_equal 1.0, @provider.send(:retry_interval)
|
|
end
|
|
|
|
test "min_request_interval returns default value" do
|
|
assert_equal 0.5, @provider.send(:min_request_interval)
|
|
end
|
|
|
|
# ================================
|
|
# Cookie/Crumb Authentication Tests
|
|
# ================================
|
|
|
|
test "extract_cookie extracts cookie from set-cookie header" do
|
|
mock_response = OpenStruct.new(
|
|
headers: { "set-cookie" => "B=abc123&b=3&s=qf; expires=Fri, 18-May-2028 00:00:00 GMT; path=/; domain=.yahoo.com" }
|
|
)
|
|
|
|
cookie = @provider.send(:extract_cookie, mock_response)
|
|
assert_equal "B=abc123&b=3&s=qf", cookie
|
|
end
|
|
|
|
test "extract_cookie returns nil when no cookie header" do
|
|
mock_response = OpenStruct.new(headers: {})
|
|
cookie = @provider.send(:extract_cookie, mock_response)
|
|
assert_nil cookie
|
|
end
|
|
|
|
test "extract_cookie_max_age parses Max-Age from cookie header" do
|
|
mock_response = OpenStruct.new(
|
|
headers: { "set-cookie" => "A3=d=xxx; Max-Age=31557600; Domain=.yahoo.com" }
|
|
)
|
|
|
|
max_age = @provider.send(:extract_cookie_max_age, mock_response)
|
|
assert_equal 31557600.seconds, max_age
|
|
end
|
|
|
|
test "extract_cookie_max_age returns nil when no Max-Age" do
|
|
mock_response = OpenStruct.new(
|
|
headers: { "set-cookie" => "A3=d=xxx; Domain=.yahoo.com" }
|
|
)
|
|
|
|
max_age = @provider.send(:extract_cookie_max_age, mock_response)
|
|
assert_nil max_age
|
|
end
|
|
|
|
test "clear_crumb_cache removes cached crumb" do
|
|
Rails.cache.write("yahoo_finance_auth_crumb", [ "cookie", "crumb" ])
|
|
@provider.send(:clear_crumb_cache)
|
|
assert_nil Rails.cache.read("yahoo_finance_auth_crumb")
|
|
end
|
|
|
|
# ================================
|
|
# Helper Method Tests
|
|
# ================================
|
|
|
|
test "map_country_code returns correct codes for exchanges" do
|
|
assert_equal "US", @provider.send(:map_country_code, "NASDAQ")
|
|
assert_equal "US", @provider.send(:map_country_code, "NYSE")
|
|
assert_equal "GB", @provider.send(:map_country_code, "LSE")
|
|
assert_equal "JP", @provider.send(:map_country_code, "TOKYO")
|
|
assert_equal "CA", @provider.send(:map_country_code, "TSX")
|
|
assert_equal "DE", @provider.send(:map_country_code, "FRANKFURT")
|
|
assert_nil @provider.send(:map_country_code, "UNKNOWN")
|
|
assert_nil @provider.send(:map_country_code, "")
|
|
end
|
|
|
|
test "map_exchange_mic returns correct MIC codes" do
|
|
assert_equal "XNAS", @provider.send(:map_exchange_mic, "NMS")
|
|
assert_equal "XNAS", @provider.send(:map_exchange_mic, "NGM")
|
|
assert_equal "XNYS", @provider.send(:map_exchange_mic, "NYQ")
|
|
assert_equal "XLON", @provider.send(:map_exchange_mic, "LSE")
|
|
assert_equal "XTSE", @provider.send(:map_exchange_mic, "TSE")
|
|
assert_equal "UNKNOWN", @provider.send(:map_exchange_mic, "UNKNOWN")
|
|
assert_nil @provider.send(:map_exchange_mic, "")
|
|
end
|
|
|
|
test "map_exchange_mic returns XIDX for JKT" do
|
|
assert_equal "XIDX", @provider.send(:map_exchange_mic, "JKT")
|
|
assert_equal "XIDX", @provider.send(:map_exchange_mic, "jkt")
|
|
end
|
|
|
|
test "map_security_type returns correct types" do
|
|
assert_equal "common stock", @provider.send(:map_security_type, "equity")
|
|
assert_equal "etf", @provider.send(:map_security_type, "etf")
|
|
assert_equal "mutual fund", @provider.send(:map_security_type, "mutualfund")
|
|
assert_equal "index", @provider.send(:map_security_type, "index")
|
|
assert_equal "unknown", @provider.send(:map_security_type, "unknown")
|
|
assert_nil @provider.send(:map_security_type, nil)
|
|
end
|
|
|
|
|
|
|
|
test "validate_date_range! raises errors for invalid ranges" do
|
|
assert_raises(Provider::YahooFinance::Error) do
|
|
@provider.send(:validate_date_range!, Date.current, Date.current - 1.day)
|
|
end
|
|
|
|
assert_raises(Provider::YahooFinance::Error) do
|
|
@provider.send(:validate_date_range!, Date.current - 6.years - 1.day, Date.current)
|
|
end
|
|
|
|
# Should not raise for valid ranges
|
|
assert_nothing_raised do
|
|
@provider.send(:validate_date_range!, Date.current - 1.year, Date.current)
|
|
@provider.send(:validate_date_range!, Date.current - 5.years, Date.current)
|
|
end
|
|
end
|
|
|
|
# ================================
|
|
# Currency Normalization Tests
|
|
# ================================
|
|
|
|
test "normalize_currency_and_price converts GBp to GBP" do
|
|
currency, price = @provider.send(:normalize_currency_and_price, "GBp", 1234.56)
|
|
assert_equal "GBP", currency
|
|
assert_equal 12.3456, price
|
|
end
|
|
|
|
test "normalize_currency_and_price converts ZAc to ZAR" do
|
|
currency, price = @provider.send(:normalize_currency_and_price, "ZAc", 5000.0)
|
|
assert_equal "ZAR", currency
|
|
assert_equal 50.0, price
|
|
end
|
|
|
|
test "normalize_currency_and_price leaves standard currencies unchanged" do
|
|
currency, price = @provider.send(:normalize_currency_and_price, "USD", 100.50)
|
|
assert_equal "USD", currency
|
|
assert_equal 100.50, price
|
|
|
|
currency, price = @provider.send(:normalize_currency_and_price, "GBP", 50.25)
|
|
assert_equal "GBP", currency
|
|
assert_equal 50.25, price
|
|
|
|
currency, price = @provider.send(:normalize_currency_and_price, "EUR", 75.75)
|
|
assert_equal "EUR", currency
|
|
assert_equal 75.75, price
|
|
end
|
|
|
|
# ================================
|
|
# Exchange Mapping Tests
|
|
# ================================
|
|
|
|
test "map_exchange_mic returns XNSE for NSE and NSI" do
|
|
assert_equal "XNSE", @provider.send(:map_exchange_mic, "NSE")
|
|
assert_equal "XNSE", @provider.send(:map_exchange_mic, "NSI")
|
|
assert_equal "XNSE", @provider.send(:map_exchange_mic, "nse")
|
|
end
|
|
|
|
test "map_exchange_mic returns XBOM for BSE and BOM" do
|
|
assert_equal "XBOM", @provider.send(:map_exchange_mic, "BSE")
|
|
assert_equal "XBOM", @provider.send(:map_exchange_mic, "BOM")
|
|
end
|
|
|
|
test "map_country_code returns IN for Indian exchanges" do
|
|
assert_equal "IN", @provider.send(:map_country_code, "NSE")
|
|
assert_equal "IN", @provider.send(:map_country_code, "BSE")
|
|
assert_equal "IN", @provider.send(:map_country_code, "MUMBAI")
|
|
end
|
|
|
|
test "map_country_code returns ID for Indonesian exchanges" do
|
|
assert_equal "ID", @provider.send(:map_country_code, "JAKARTA")
|
|
assert_equal "ID", @provider.send(:map_country_code, "IDX")
|
|
end
|
|
|
|
# ================================
|
|
# normalize_symbol Tests
|
|
# ================================
|
|
|
|
test "normalize_symbol appends configured suffix for known MICs" do
|
|
assert_equal "RELIANCE.NS", @provider.send(:normalize_symbol, "RELIANCE", "XNSE")
|
|
assert_equal "INFY.NS", @provider.send(:normalize_symbol, "INFY", "XNSE")
|
|
assert_equal "500325.BO", @provider.send(:normalize_symbol, "500325", "XBOM")
|
|
assert_equal "BBCA.JK", @provider.send(:normalize_symbol, "BBCA", "XIDX")
|
|
end
|
|
|
|
test "normalize_symbol does not double-suffix already suffixed symbols" do
|
|
assert_equal "RELIANCE.NS", @provider.send(:normalize_symbol, "RELIANCE.NS", "XNSE")
|
|
assert_equal "500325.BO", @provider.send(:normalize_symbol, "500325.BO", "XBOM")
|
|
assert_equal "BBCA.JK", @provider.send(:normalize_symbol, "BBCA.JK", "XIDX")
|
|
end
|
|
|
|
test "normalize_symbol leaves unconfigured MIC symbols unchanged" do
|
|
assert_equal "AAPL", @provider.send(:normalize_symbol, "AAPL", "XNAS")
|
|
assert_equal "BARC", @provider.send(:normalize_symbol, "BARC", "XLON")
|
|
assert_equal "AAPL", @provider.send(:normalize_symbol, "AAPL", nil)
|
|
end
|
|
|
|
test "normalize_symbol appends suffix to dotted symbols that do not already end with the configured suffix" do
|
|
assert_equal "BRK.A.NS", @provider.send(:normalize_symbol, "BRK.A", "XNSE")
|
|
assert_equal "BRK.B.BO", @provider.send(:normalize_symbol, "BRK.B", "XBOM")
|
|
end
|
|
|
|
# ================================
|
|
# default_currency_for_exchange Tests
|
|
# ================================
|
|
|
|
test "default_currency_for_exchange returns configured currency for known Yahoo exchange names" do
|
|
assert_equal "INR", @provider.send(:default_currency_for_exchange, "NSE")
|
|
assert_equal "INR", @provider.send(:default_currency_for_exchange, "BSE")
|
|
assert_equal "IDR", @provider.send(:default_currency_for_exchange, "JKT")
|
|
end
|
|
|
|
test "default_currency_for_exchange returns nil for unknown exchanges" do
|
|
assert_nil @provider.send(:default_currency_for_exchange, "UNKNOWN")
|
|
assert_nil @provider.send(:default_currency_for_exchange, "NMS")
|
|
end
|
|
|
|
# ================================
|
|
# deduplicate_dual_listings Tests
|
|
# ================================
|
|
|
|
test "deduplicate_dual_listings keeps preferred exchange when both are present" do
|
|
nse = Provider::SecurityConcept::Security.new(symbol: "RELIANCE.NS", name: "Reliance", logo_url: nil, exchange_operating_mic: "XNSE", country_code: "IN")
|
|
bse = Provider::SecurityConcept::Security.new(symbol: "500325.BO", name: "Reliance", logo_url: nil, exchange_operating_mic: "XBOM", country_code: "IN")
|
|
other = Provider::SecurityConcept::Security.new(symbol: "OTHER", name: "Other", logo_url: nil, exchange_operating_mic: "XNAS", country_code: "US")
|
|
|
|
result = @provider.send(:deduplicate_dual_listings, [ nse, bse, other ])
|
|
|
|
assert_equal "XNSE", result.first.exchange_operating_mic
|
|
assert_not result.map(&:exchange_operating_mic).include?("XBOM"), "Lower-ranked exchange should be removed"
|
|
assert result.map(&:exchange_operating_mic).include?("XNAS"), "Non-dual-listed exchanges should be preserved"
|
|
end
|
|
|
|
test "deduplicate_dual_listings preserves unrelated securities in the same dual_list_group" do
|
|
reliance_nse = Provider::SecurityConcept::Security.new(symbol: "RELIANCE.NS", name: "Reliance Industries", logo_url: nil, exchange_operating_mic: "XNSE", country_code: "IN")
|
|
reliance_bse = Provider::SecurityConcept::Security.new(symbol: "500325.BO", name: "Reliance Industries", logo_url: nil, exchange_operating_mic: "XBOM", country_code: "IN")
|
|
infy_nse = Provider::SecurityConcept::Security.new(symbol: "INFY.NS", name: "Infosys", logo_url: nil, exchange_operating_mic: "XNSE", country_code: "IN")
|
|
other = Provider::SecurityConcept::Security.new(symbol: "AAPL", name: "Apple", logo_url: nil, exchange_operating_mic: "XNAS", country_code: "US")
|
|
|
|
result = @provider.send(:deduplicate_dual_listings, [ reliance_nse, reliance_bse, infy_nse, other ])
|
|
|
|
symbols = result.map(&:symbol)
|
|
assert_includes symbols, "RELIANCE.NS", "Preferred listing should be kept"
|
|
assert_not_includes symbols, "500325.BO", "Duplicate listing should be removed"
|
|
assert_includes symbols, "INFY.NS", "Unrelated security in same group should be preserved"
|
|
assert_includes symbols, "AAPL", "Non-dual-listed security should be preserved"
|
|
assert_equal 3, result.size
|
|
end
|
|
|
|
test "deduplicate_dual_listings returns original list when no dual-listed exchanges present" do
|
|
securities = [
|
|
Provider::SecurityConcept::Security.new(symbol: "AAPL", name: "Apple", logo_url: nil, exchange_operating_mic: "XNAS", country_code: "US"),
|
|
Provider::SecurityConcept::Security.new(symbol: "MSFT", name: "Microsoft", logo_url: nil, exchange_operating_mic: "XNAS", country_code: "US")
|
|
]
|
|
|
|
result = @provider.send(:deduplicate_dual_listings, securities)
|
|
assert_equal securities, result
|
|
end
|
|
|
|
private
|
|
|
|
def stub_successful_health_authentication(provider: @provider, count: 1)
|
|
responses = count.times.flat_map do |index|
|
|
[
|
|
cookie_health_response("test-cookie-#{index}"),
|
|
health_response(status: 200, body: "test-crumb-#{index}")
|
|
]
|
|
end
|
|
auth_client = mock
|
|
auth_client.expects(:get).times(responses.length).returns(*responses)
|
|
provider.stubs(:health_auth_client).returns(auth_client)
|
|
end
|
|
|
|
def stub_health_chart_responses(*responses, provider: @provider)
|
|
chart_client = mock
|
|
chart_client.expects(:get).times(responses.length).returns(*responses)
|
|
provider.stubs(:health_authenticated_client).returns(chart_client)
|
|
end
|
|
|
|
def cookie_health_response(cookie)
|
|
health_response(
|
|
status: 404,
|
|
headers: { "set-cookie" => "A3=#{cookie}; Max-Age=3600" }
|
|
)
|
|
end
|
|
|
|
def healthy_chart_response
|
|
health_response(
|
|
status: 200,
|
|
body: '{"chart":{"result":[{"meta":{"symbol":"AAPL"}}]}}'
|
|
)
|
|
end
|
|
|
|
def health_response(status:, body: "", headers: {})
|
|
OpenStruct.new(
|
|
status: status,
|
|
body: body,
|
|
headers: headers,
|
|
success?: status.between?(200, 299)
|
|
)
|
|
end
|
|
end
|