Files
sure/test/models/provider/yahoo_finance_test.rb
Serge L 15cfcf585d Fix: Yahoo Finance provider Cookie/Crumb Auth (#1082)
* Fix: use cookie/crumb auth in healthy? chart endpoint check

The health check was calling /v8/finance/chart/AAPL via the plain
unauthenticated client. Yahoo Finance requires cookie + crumb
authentication on the chart endpoint, so the health check would
fail even when credentials are valid. Updated healthy? to use
fetch_cookie_and_crumb + authenticated_client, consistent with
fetch_security_prices and fetch_chart_data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix: add cookie/crumb auth to all /v8/finance/chart/ calls

fetch_security_prices and fetch_chart_data (used for exchange rates)
were calling the chart endpoint without cookie/crumb authentication,
inconsistent with healthy? and fetch_security_info. Added auth to both,
including the same retry-on-Unauthorized pattern already used in
fetch_security_info.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Update user-agent strings in yahoo_finance.rb

Updated user-agent strings to reflect current browser versions

Signed-off-by: Serge L <serge@souritech.ca>

* Fix: Add stale-crumb retry to healthy? and fetch_chart_data

Yahoo Finance returns 200 OK with {"chart":{"error":{"code":"Unauthorized"}}}
when a cached crumb expires server-side. Both healthy? and fetch_chart_data
now mirror the retry pattern already in fetch_security_prices: detect the
Unauthorized body, clear the crumb cache, fetch fresh credentials, and
retry the request once. Adds a test for the healthy? retry path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Refactor: Extract fetch_authenticated_chart helper to DRY crumb retry logic

The cookie/crumb fetch + stale-crumb retry pattern was duplicated across
healthy?, fetch_security_prices, and fetch_chart_data. Extract it into a
single private fetch_authenticated_chart(symbol, params) helper that
centralizes the retry logic; all three call sites now delegate to it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix: Catch JSON::ParserError in fetch_chart_data rescue clause

After moving JSON.parse inside fetch_authenticated_chart, a malformed
Yahoo response would throw JSON::ParserError through fetch_chart_data's
rescue Faraday::Error, breaking the inverse currency pair fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix: Raise AuthenticationError if retry still returns Unauthorized

After refreshing the crumb and retrying, if Yahoo still returns an
Unauthorized error body the helper now raises AuthenticationError instead
of silently returning the error payload. This prevents callers from
misinterpreting a persistent auth failure as missing chart data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix: Raise AuthenticationError after failed retry in fetch_security_info

Mirrors the same post-retry Unauthorized check added to fetch_authenticated_chart.
Without this, a persistent auth failure on the quoteSummary endpoint would
surface as a generic "No security info found" error instead of an AuthenticationError.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Signed-off-by: Serge L <serge@souritech.ca>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 22:48:48 +01:00

371 lines
14 KiB
Ruby

require "test_helper"
class Provider::YahooFinanceTest < ActiveSupport::TestCase
setup do
@provider = Provider::YahooFinance.new
end
# ================================
# Health Check Tests
# ================================
test "healthy? returns true when API is working" do
mock_response = mock
mock_response.stubs(:body).returns('{"chart":{"result":[{"meta":{"symbol":"AAPL"}}]}}')
@provider.stubs(:fetch_cookie_and_crumb).returns([ "test_cookie", "test_crumb" ])
@provider.stubs(:authenticated_client).returns(mock_client = mock)
mock_client.stubs(:get).returns(mock_response)
assert @provider.healthy?
end
test "healthy? returns false when API fails" do
@provider.stubs(:fetch_cookie_and_crumb).raises(Provider::YahooFinance::AuthenticationError.new("auth failed"))
assert_not @provider.healthy?
end
test "healthy? retries with fresh crumb on Unauthorized body response" do
unauthorized_body = '{"chart":{"error":{"code":"Unauthorized","description":"No crumb"}}}'
success_body = '{"chart":{"result":[{"meta":{"symbol":"AAPL"}}]}}'
unauthorized_response = mock
unauthorized_response.stubs(:body).returns(unauthorized_body)
success_response = mock
success_response.stubs(:body).returns(success_body)
mock_client = mock
mock_client.stubs(:get).returns(unauthorized_response, success_response)
@provider.stubs(:fetch_cookie_and_crumb).returns([ "cookie1", "crumb1" ], [ "cookie2", "crumb2" ])
@provider.stubs(:authenticated_client).returns(mock_client)
@provider.expects(:clear_crumb_cache).once
assert @provider.healthy?
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
# ================================
# 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
# ================================
# 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 "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_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
end