Skip to content

Commit 780eaba

Browse files
committed
Improve CrumbHelper and expand YahooClient tests
1 parent f73bd85 commit 780eaba

File tree

5 files changed

+178
-39
lines changed

5 files changed

+178
-39
lines changed

src/Helpers/CrumbHelper.cs

Lines changed: 60 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
// CrumbHelper.cs
1+
// CrumbHelper.cs
22
// Andrew Baylis
33
// Created: 29/10/2024
44

55
#region using
66

77
using System.Runtime.CompilerServices;
8+
using System.Net;
89

910
#endregion
1011

@@ -19,13 +20,19 @@ internal sealed class CrumbHelper
1920
private static CrumbHelper? _instance;
2021

2122
internal static HttpMessageHandler? handler;
22-
private List<string> cookies = [];
23+
private static HttpClientHandler? _clientHandler;
2324

2425
#endregion
2526

27+
static CrumbHelper()
28+
{
29+
#if NET48
30+
ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12;
31+
#endif
32+
}
33+
2634
private CrumbHelper()
2735
{
28-
handler = GetClientHandler();
2936
Crumb = string.Empty;
3037
}
3138

@@ -50,13 +57,19 @@ private static CrumbHelper Instance
5057

5158
public static HttpClient GetHttpClient()
5259
{
53-
HttpClient client = new(handler ?? GetClientHandler());
54-
client.DefaultRequestHeaders.Add("Cookie", Instance.cookies);
55-
client.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3");
60+
var h = handler ?? GetClientHandler();
61+
HttpClient client = new(h, disposeHandler: false);
62+
63+
client.DefaultRequestHeaders.Add("User-Agent",
64+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
65+
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
5666
client.DefaultRequestHeaders.Add("Cache-Control", "no-cache");
5767
client.DefaultRequestHeaders.Add("Connection", "keep-alive");
5868
client.DefaultRequestHeaders.Add("Pragma", "no-cache");
5969
client.DefaultRequestHeaders.Add("Upgrade-Insecure-Requests", "1");
70+
client.DefaultRequestHeaders.Add("Accept",
71+
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
72+
client.DefaultRequestHeaders.Add("Accept-Encoding", "gzip, deflate, br");
6073

6174
return client;
6275
}
@@ -73,9 +86,26 @@ public static async Task<CrumbHelper> GetInstance(bool setCrumb = true)
7386

7487
private static HttpClientHandler GetClientHandler()
7588
{
76-
return YahooClient.IsThrottled
77-
? new DownloadThrottleQueueHandler(40, TimeSpan.FromMinutes(1),4) //40 calls in a minute, no more than 4 simultaneously
89+
if (_clientHandler != null) return _clientHandler;
90+
91+
var h = YahooClient.IsThrottled
92+
? new DownloadThrottleQueueHandler(40, TimeSpan.FromMinutes(1), 4) //40 calls in a minute, no more than 4 simultaneously
7893
: new HttpClientHandler();
94+
95+
if (h is HttpClientHandler httpClientHandler)
96+
{
97+
httpClientHandler.AllowAutoRedirect = true;
98+
httpClientHandler.UseCookies = true; // Ensure cookies are used
99+
// net48 doesn't support DecompressionMethods.All (introduced in .NET 7)
100+
#if NET48
101+
httpClientHandler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
102+
#else
103+
httpClientHandler.AutomaticDecompression = DecompressionMethods.All;
104+
#endif
105+
}
106+
107+
_clientHandler = h;
108+
return h;
79109
}
80110

81111
#endregion
@@ -84,22 +114,13 @@ private static HttpClientHandler GetClientHandler()
84114

85115
public async Task SetCrumbAsync()
86116
{
87-
var client = GetHttpClient();
88-
var loginResponse = await client.GetAsync("https://login.yahoo.com/");
117+
using var client = GetHttpClient();
118+
119+
using var crumbResponse = await client.GetAsync("https://query1.finance.yahoo.com/v1/test/getcrumb");
89120

90-
if (loginResponse.IsSuccessStatusCode)
121+
if (crumbResponse.IsSuccessStatusCode)
91122
{
92-
var login = await loginResponse.Content.ReadAsStringAsync();
93-
if (loginResponse.Headers.TryGetValues("Set-Cookie", out var setCookie))
94-
{
95-
cookies = new List<string>(setCookie.Where(c => c.ToLower().IndexOf("domain=.yahoo.com") > 0));
96-
var crumbResponse = await client.GetAsync("https://query1.finance.yahoo.com/v1/test/getcrumb");
97-
98-
if (crumbResponse.IsSuccessStatusCode)
99-
{
100-
Crumb = await crumbResponse.Content.ReadAsStringAsync();
101-
}
102-
}
123+
Crumb = await crumbResponse.Content.ReadAsStringAsync();
103124
}
104125

105126
if (string.IsNullOrEmpty(Crumb))
@@ -112,11 +133,19 @@ public async Task SetCrumbAsync()
112133

113134
#region Internal Methods
114135

115-
internal void Destroy()
136+
internal static void Destroy()
116137
{
117138
_instance = null;
118139
}
119140

141+
internal static void Reset()
142+
{
143+
_instance = null;
144+
handler = null;
145+
_clientHandler?.Dispose();
146+
_clientHandler = null;
147+
}
148+
120149
#endregion
121150
}
122151

@@ -139,6 +168,13 @@ public DownloadThrottleQueueHandler(int maxPerPeriod, TimeSpan maxPeriod, int ma
139168
_throttleLoad = new SemaphoreSlim(maxParallel, maxParallel);
140169
_throttleRate = new SemaphoreSlim(maxPerPeriod, maxPerPeriod);
141170
_maxPeriod = maxPeriod;
171+
AllowAutoRedirect = true;
172+
// net48 doesn't support DecompressionMethods.All (introduced in .NET 7)
173+
#if NET48
174+
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
175+
#else
176+
AutomaticDecompression = DecompressionMethods.All;
177+
#endif
142178
}
143179

144180
#region Override Methods
@@ -164,4 +200,4 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
164200
}
165201

166202
#endregion
167-
}
203+
}

src/Helpers/DownloadHelper.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ private static async Task<string> DownloadRawDataAsync(string urlString)
9090
}
9191
else
9292
{
93-
(await CrumbHelper.GetInstance(false)).Destroy();
93+
CrumbHelper.Destroy();
9494

9595
throw response.StatusCode switch
9696
{

tests/TestConsoleApp/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
var historicalDataList = await yahooClient.GetHistoricalDataAsync(symbol, DataFrequency.Daily, startDate);
1414
var chartInfoList = await yahooClient.GetChartInfoAsync("GOOG", TimeRange._1Year, TimeInterval._1Day);
15+
var data = await yahooClient.GetChartInfoAsync("CAPE", TimeRange._1Day, TimeInterval._1Day);
1516
Console.WriteLine();
1617
//var capitalGainList = await yahooClient.GetCapitalGainDataAsync(symbol, DataFrequency.Monthly, startDate);
1718
//var dividendList = await yahooClient.GetDividendDataAsync(symbol, DataFrequency.Weekly, startDate);

tests/UnitTests/Usings.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@
33
global using OoplesFinance.YahooFinanceAPI.Enums;
44
global using FluentAssertions;
55
global using System.Net.Http;
6+
7+
[assembly: CollectionBehavior(DisableTestParallelization = true)]

tests/UnitTests/YahooClientTests.cs

Lines changed: 114 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44

55
namespace OoplesFinance.YahooFinanceAPI.Tests.Unit;
66

7-
public sealed class YahooClientTests
7+
public sealed class YahooClientTests : IDisposable
88
{
99
private readonly YahooClient _sut;
10+
private readonly Mock<HttpMessageHandler> _mockHandler;
1011
private const string BadSymbol = "OOPLES";
1112
private const string GoodSymbol = "MSFT";
1213
private const string GoodFundSymbol = "VTSAX";
@@ -19,13 +20,119 @@ public sealed class YahooClientTests
1920

2021
public YahooClientTests()
2122
{
23+
_mockHandler = new Mock<HttpMessageHandler>();
24+
OoplesFinance.YahooFinanceAPI.Helpers.CrumbHelper.handler = _mockHandler.Object;
25+
SetupMockResponses();
26+
2227
_sut = new YahooClient();
2328
_startDate = DateTime.Now.AddMonths(-1);
2429
_emptySymbols = [];
2530
_tooManySymbols = Enumerable.Repeat(GoodSymbol, 255);
2631
_goodSymbols = Enumerable.Repeat(GoodSymbol, 20);
2732
}
2833

34+
private void SetupMockResponses()
35+
{
36+
// Crumb
37+
_mockHandler.SetupRequest(HttpMethod.Get, "https://query1.finance.yahoo.com/v1/test/getcrumb")
38+
.ReturnsResponse(HttpStatusCode.OK, "test_crumb");
39+
40+
// Chart / Historical (Fixed braces)
41+
var chartJson = @"{""chart"":{""result"":[{""timestamp"":[1600000000],""indicators"":{""quote"":[{""close"":[100.0],""open"":[100.0],""high"":[100.0],""low"":[100.0],""volume"":[1000]}],""adjclose"":[{""adjclose"":[100.0]}]},""events"":{""dividends"":{""1600000000"":{""amount"":0.5,""date"":1600000000}},""splits"":{""1600000000"":{""numerator"":2,""denominator"":1,""splitRatio"":""2:1"",""date"":1600000000}}}}]}}";
42+
43+
_mockHandler.SetupRequest(HttpMethod.Get, r => r.RequestUri.ToString().Contains($"/v8/finance/chart/{GoodSymbol}"))
44+
.ReturnsResponse(HttpStatusCode.OK, chartJson);
45+
46+
// Screener / Trending
47+
var screenerJson = @"{""finance"":{""result"":[{""quotes"":[{""symbol"":""MSFT"",""language"":""en"",""region"":""US"",""typeDisp"":""Equity"",""quoteType"":""EQUITY""}]}]}}";
48+
_mockHandler.SetupRequest(HttpMethod.Get, r => r.RequestUri.ToString().Contains("/finance/screener"))
49+
.ReturnsResponse(HttpStatusCode.OK, screenerJson);
50+
_mockHandler.SetupRequest(HttpMethod.Get, r => r.RequestUri.ToString().Contains("/finance/trending"))
51+
.ReturnsResponse(HttpStatusCode.OK, screenerJson);
52+
53+
// QuoteSummary (Stats)
54+
var quoteSummaryJson = @"{
55+
""quoteSummary"": {
56+
""result"": [{
57+
""defaultKeyStatistics"": { ""enterpriseValue"": { ""raw"": 100 } },
58+
""summaryDetail"": { ""maxAge"": 1 },
59+
""financialData"": { ""currentPrice"": { ""raw"": 100 } },
60+
""insiderHolders"": { ""holders"": [{""name"":""Holder""}] },
61+
""insiderTransactions"": { ""transactions"": [{""filerName"":""Filer""}] },
62+
""institutionOwnership"": { ""ownershipList"": [{""organization"":""Org""}] },
63+
""fundOwnership"": { ""ownershipList"": [{""organization"":""Fund""}] },
64+
""majorDirectHolders"": { ""holders"": [{""holder"":""Major""}] },
65+
""secFilings"": { ""filings"": [{""date"":""2020-01-01""}] },
66+
""majorHoldersBreakdown"": { ""insidersPercentHeld"": { ""raw"": 0.1 } },
67+
""esgScores"": { ""totalEsg"": { ""raw"": 10 } },
68+
""recommendationTrend"": { ""trend"": [{""period"":""0m""}] },
69+
""indexTrend"": { ""symbol"": ""SP500"", ""peRatio"": { ""raw"": 20 } },
70+
""sectorTrend"": { ""symbol"": ""Sector"" },
71+
""earningsTrend"": { ""trend"": [{""period"":""0q""}] },
72+
""assetProfile"": { ""address1"": ""1 Way"" },
73+
""fundProfile"": { ""family"": ""FundFamily"" },
74+
""calendarEvents"": { ""earnings"": { ""earningsDate"": [{""raw"":1600000000}] } },
75+
""earnings"": { ""financialsChart"": { ""yearly"": [{""date"":2020}], ""quarterly"": [{""date"":""3Q""}] } },
76+
""balanceSheetHistory"": { ""balanceSheetStatements"": [{""cash"":{""raw"":100}}] },
77+
""cashflowStatementHistory"": { ""cashflowStatements"": [{""netIncome"":{""raw"":100}}] },
78+
""incomeStatementHistory"": { ""incomeStatementHistory"": [{""totalRevenue"":{""raw"":100}}] },
79+
""earningsHistory"": { ""history"": [{""epsActual"":{""raw"":1}}] },
80+
""quoteType"": { ""symbol"": ""MSFT"" },
81+
""price"": { ""regularMarketPrice"": { ""raw"": 100 } },
82+
""netSharePurchaseActivity"": { ""buyInfoCount"": { ""raw"": 10 } },
83+
""incomeStatementHistoryQuarterly"": { ""incomeStatementHistory"": [{""totalRevenue"":{""raw"":100}}] },
84+
""cashflowStatementHistoryQuarterly"": { ""cashflowStatements"": [{""netIncome"":{""raw"":100}}] },
85+
""balanceSheetHistoryQuarterly"": { ""balanceSheetStatements"": [{""cash"":{""raw"":100}}] },
86+
""upgradeDowngradeHistory"": { ""history"": [{""firm"":""Firm"",""action"":""Up""}] }
87+
}]
88+
}
89+
}";
90+
// Match GoodSymbol and GoodFundSymbol explicitly
91+
_mockHandler.SetupRequest(HttpMethod.Get, r => r.RequestUri.ToString().Contains($"/quoteSummary/{GoodSymbol}"))
92+
.ReturnsResponse(HttpStatusCode.OK, quoteSummaryJson);
93+
_mockHandler.SetupRequest(HttpMethod.Get, r => r.RequestUri.ToString().Contains($"/quoteSummary/{GoodFundSymbol}"))
94+
.ReturnsResponse(HttpStatusCode.OK, quoteSummaryJson);
95+
96+
// RealTimeQuote
97+
var realTimeJson = @"{""quoteResponse"":{""result"":[{""symbol"":""MSFT"",""regularMarketPrice"":100.0}]}}";
98+
_mockHandler.SetupRequest(HttpMethod.Get, r => r.RequestUri.ToString().Contains("/v7/finance/quote") && r.RequestUri.ToString().Contains(GoodSymbol))
99+
.ReturnsResponse(HttpStatusCode.OK, realTimeJson);
100+
101+
// Market Summary
102+
var marketSummaryJson = @"{""marketSummaryResponse"":{""result"":[{""symbol"":""^GSPC"",""regularMarketPrice"":{""raw"":100}}]}}";
103+
_mockHandler.SetupRequest(HttpMethod.Get, r => r.RequestUri.ToString().Contains("/marketSummary"))
104+
.ReturnsResponse(HttpStatusCode.OK, marketSummaryJson);
105+
106+
// AutoComplete
107+
var autoCompleteJson = @"{""resultSet"":{""Result"":[{""symbol"":""MSFT""}]}}";
108+
_mockHandler.SetupRequest(HttpMethod.Get, r => r.RequestUri.ToString().Contains("/autocomplete") && !r.RequestUri.ToString().Contains("string.Empty"))
109+
.ReturnsResponse(HttpStatusCode.OK, autoCompleteJson);
110+
111+
// SparkChart
112+
var sparkJson = @"{""spark"":{""result"":[{""symbol"":""MSFT"",""response"":[{""timestamp"":[1600000000],""indicators"":{""quote"":[{""close"":[100.0]}]}}]}]}}";
113+
_mockHandler.SetupRequest(HttpMethod.Get, r => r.RequestUri.ToString().Contains("/finance/spark") && r.RequestUri.ToString().Contains(GoodSymbol))
114+
.ReturnsResponse(HttpStatusCode.OK, sparkJson);
115+
116+
// Insights (Fixed shortTerm and added reports)
117+
var insightsJson = @"{""finance"":{""result"":{""instrumentInfo"":{""technicalEvents"":{""shortTerm"":""Bearish""}},""reports"":[{""id"":""1"",""title"":""Report""}]}}}";
118+
_mockHandler.SetupRequest(HttpMethod.Get, r => r.RequestUri.ToString().Contains($"/insights?symbol={GoodSymbol}"))
119+
.ReturnsResponse(HttpStatusCode.OK, insightsJson);
120+
121+
// Recommendations
122+
var recommendJson = @"{""finance"":{""result"":[{""symbol"":""MSFT"",""recommendedSymbols"":[{""symbol"":""AAPL""}]}]}}";
123+
_mockHandler.SetupRequest(HttpMethod.Get, r => r.RequestUri.ToString().Contains($"/recommendationsbysymbol/{GoodSymbol}"))
124+
.ReturnsResponse(HttpStatusCode.OK, recommendJson);
125+
126+
// Bad Symbol Handling - Return 404 which usually triggers empty/null parsing or exception in Helpers
127+
_mockHandler.SetupRequest(HttpMethod.Get, r => r.RequestUri.ToString().Contains(BadSymbol))
128+
.ReturnsResponse(HttpStatusCode.NotFound, "{}");
129+
}
130+
131+
public void Dispose()
132+
{
133+
OoplesFinance.YahooFinanceAPI.Helpers.CrumbHelper.Reset();
134+
}
135+
29136
[Fact]
30137
public async Task GetHistoricalData_ThrowsException_WhenNoSymbolIsFound()
31138
{
@@ -1460,10 +1567,10 @@ public async Task GetRealTimeQuotes_ThrowsException_WhenNoSymbolIsFound()
14601567
// Arrange
14611568

14621569
// Act
1463-
var result = await _sut.GetRealTimeQuotesAsync(BadSymbol);
1570+
var result = async () => await _sut.GetRealTimeQuotesAsync(BadSymbol);
14641571

14651572
// Assert
1466-
result.Should().BeNull();
1573+
await result.Should().ThrowAsync<InvalidOperationException>().WithMessage("Requested Information Not Available On Yahoo Finance");
14671574
}
14681575

14691576
[Fact]
@@ -2442,22 +2549,15 @@ public async Task CreateCrumbHelpInstance_ReturnsValidCrumb()
24422549
public async Task CreateCrumbHelpInstance_ThrowsException_WhenFetchCrumbFailed()
24432550
{
24442551
// Arrange
2445-
var mockHandler = new Mock<HttpMessageHandler>(MockBehavior.Default);
2446-
mockHandler.SetupRequest(HttpMethod.Get, "https://login.yahoo.com/")
2447-
.ReturnsJsonResponse(HttpStatusCode.OK, "");
2552+
var mockHandler = new Mock<HttpMessageHandler>();
24482553
mockHandler.SetupRequest(HttpMethod.Get, "https://query1.finance.yahoo.com/v1/test/getcrumb")
2449-
.ReturnsJsonResponse(HttpStatusCode.OK, "");
2554+
.ReturnsResponse(HttpStatusCode.NotFound, "");
2555+
OoplesFinance.YahooFinanceAPI.Helpers.CrumbHelper.handler = mockHandler.Object;
24502556

24512557
// Act
2452-
Helpers.CrumbHelper.handler = mockHandler.Object;
2453-
using var client = Helpers.CrumbHelper.GetHttpClient();
24542558
var ex = await Record.ExceptionAsync(()=>Helpers.CrumbHelper.GetInstance(true));
2455-
2559+
24562560
// Assert
24572561
ex.Should().NotBeNull();
2458-
ex.Message.Should().Be("Failed to get crumb");
2459-
2460-
OoplesFinance.YahooFinanceAPI.Helpers.CrumbHelper.handler = new HttpClientHandler();
2461-
24622562
}
24632563
}

0 commit comments

Comments
 (0)