-
Notifications
You must be signed in to change notification settings - Fork 12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Updating SDK to 0.5.2 spec #23
Changes from 10 commits
d8bb875
a1025e8
41888d7
12776f0
fafdf68
93d8c7e
56e6e7c
3c0caff
4bc4c0d
9623452
4cc1732
a182c6e
b96558c
880b499
f374527
e5b2021
a4cf071
d393950
b6fb94c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
using System; | ||
using System.Collections.Generic; | ||
using System.ComponentModel; | ||
using System.Linq; | ||
using System.Text; | ||
using System.Threading.Tasks; | ||
using GrowthBook.Api; | ||
using Microsoft.Extensions.Logging; | ||
using Moq; | ||
|
||
namespace GrowthBook.Tests.ApiTests; | ||
|
||
public abstract class ApiUnitTest<T> : UnitTest | ||
{ | ||
protected const string FirstFeatureId = nameof(FirstFeatureId); | ||
protected const string SecondFeatureId = nameof(SecondFeatureId); | ||
|
||
protected readonly ILogger<T> _logger; | ||
protected readonly Mock<IGrowthBookFeatureCache> _cache; | ||
protected readonly FeatureRefreshWorker _worker; | ||
protected readonly Feature _firstFeature; | ||
protected readonly Feature _secondFeature; | ||
protected readonly Dictionary<string, Feature> _availableFeatures; | ||
|
||
public ApiUnitTest() | ||
{ | ||
_logger = Mock.Of<ILogger<T>>(); | ||
_cache = StrictMockOf<IGrowthBookFeatureCache>(); | ||
|
||
_firstFeature = new() { DefaultValue = 1 }; | ||
_secondFeature = new() { DefaultValue = 2 }; | ||
_availableFeatures = new() | ||
{ | ||
[FirstFeatureId] = _firstFeature, | ||
[SecondFeatureId] = _secondFeature | ||
}; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,181 @@ | ||
using System; | ||
using System.Collections.Concurrent; | ||
using System.Collections.Generic; | ||
using System.ComponentModel; | ||
using System.IO; | ||
using System.Linq; | ||
using System.Net; | ||
using System.Net.Http; | ||
using System.Text; | ||
using System.Threading; | ||
using System.Threading.Tasks; | ||
using FluentAssertions; | ||
using GrowthBook.Api; | ||
using Microsoft.Extensions.Logging; | ||
using Moq; | ||
using Newtonsoft.Json; | ||
using Xunit; | ||
|
||
namespace GrowthBook.Tests.ApiTests; | ||
|
||
public class FeatureRefreshWorkerTests : ApiUnitTest<FeatureRefreshWorker> | ||
{ | ||
public class TestDelegatingHandler : DelegatingHandler | ||
{ | ||
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _handler; | ||
|
||
public TestDelegatingHandler(HttpStatusCode statusCode, string jsonContent, string streamJsonContent, bool isServerSideEventsEnabled) | ||
{ | ||
// This infrastructure is built for the purpose of making the background listener | ||
// integration test work properly, so it has several pieces of logic that will be called out here | ||
// for future reference. | ||
|
||
// We're keeping track of the handle count to determine whether to return a string or a stream. | ||
// If we need to do more of these tests in the future, this should be refactored and cleaned up. | ||
|
||
var handleCount = 0; | ||
|
||
_handler = (request, cancellationToken) => | ||
{ | ||
// Don't allow more than a single string and single stream content because the | ||
// integration test is geared towards a finite amount of responses being recorded. | ||
|
||
if (handleCount >= 2) | ||
{ | ||
return null; | ||
} | ||
|
||
HttpContent content = (isServerSideEventsEnabled && handleCount >= 1) switch | ||
{ | ||
true => new StreamContent(new MemoryStream(Encoding.UTF8.GetBytes($"data: {streamJsonContent}"))), | ||
false => new StringContent(jsonContent) | ||
}; | ||
|
||
var response = new HttpResponseMessage(statusCode) { Content = content }; | ||
|
||
if (isServerSideEventsEnabled) | ||
{ | ||
// Indicate in the HTTP response that the server sent events are supported | ||
// in order to allow kicking off the background listener. | ||
|
||
response.Headers.Add("x-sse-support", "enabled"); | ||
} | ||
|
||
handleCount++; | ||
|
||
return Task.FromResult(response); | ||
}; | ||
} | ||
|
||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) | ||
{ | ||
return _handler(request, cancellationToken); | ||
} | ||
} | ||
|
||
private class TestHttpClientFactory : HttpClientFactory | ||
{ | ||
private TestDelegatingHandler _handler; | ||
public bool IsServerSentEventsEnabled { get; set; } | ||
public Dictionary<string, Feature> ResponseContent { get; set; } | ||
public Dictionary<string, Feature> StreamResponseContent { get; set; } | ||
public HttpStatusCode ResponseStatusCode { get; set; } = HttpStatusCode.OK; | ||
|
||
protected internal override HttpClient CreateClient(Func<HttpClient, HttpClient> configure) | ||
{ | ||
// We're sending both of the string and stream contents because the handler will serve up both of them. | ||
// Also, we're reusing the handler here so we can accurately keep track of the shared call amounts | ||
// between the two paths. | ||
|
||
var json = JsonConvert.SerializeObject(new FeaturesResponse { Features = ResponseContent }); | ||
var streamJson = JsonConvert.SerializeObject(new FeaturesResponse { Features = StreamResponseContent }); | ||
var httpClient = new HttpClient(_handler ??= new TestDelegatingHandler(ResponseStatusCode, json, streamJson, IsServerSentEventsEnabled)); | ||
|
||
return configure(httpClient); | ||
} | ||
} | ||
|
||
private sealed class FeaturesResponse | ||
{ | ||
public int FeatureCount { get; set; } | ||
public Dictionary<string, Feature> Features { get; set; } | ||
public string EncryptedFeatures { get; set; } | ||
} | ||
|
||
private readonly TestHttpClientFactory _httpClientFactory; | ||
private readonly GrowthBookConfigurationOptions _config; | ||
private readonly FeatureRefreshWorker _worker; | ||
Check warning on line 107 in GrowthBook.Tests/ApiTests/FeatureRefreshWorkerTests.cs GitHub Actions / build (3.1.x)
Check warning on line 107 in GrowthBook.Tests/ApiTests/FeatureRefreshWorkerTests.cs GitHub Actions / build (6.0.x)
Check warning on line 107 in GrowthBook.Tests/ApiTests/FeatureRefreshWorkerTests.cs GitHub Actions / build (7.0.x)
|
||
|
||
public FeatureRefreshWorkerTests() | ||
{ | ||
_config = new(); | ||
_httpClientFactory = new TestHttpClientFactory(); | ||
_httpClientFactory.ResponseContent = _availableFeatures; | ||
_httpClientFactory.StreamResponseContent = _availableFeatures.Take(1).ToDictionary(x => x.Key, x => x.Value); | ||
_worker = new(_logger, _httpClientFactory, _config, _cache.Object); | ||
} | ||
|
||
[Fact] | ||
public async Task HttpRequestWithNonSuccessfulStatusResponseWillReturnNull() | ||
{ | ||
_httpClientFactory.ResponseStatusCode = HttpStatusCode.InternalServerError; | ||
|
||
var features = await _worker.RefreshCacheFromApi(); | ||
|
||
features.Should().BeNull("because the HTTP status code in the response was not in the 200-299 range"); | ||
} | ||
|
||
[Fact] | ||
public async Task HttpRequestWithSuccessStatusThatPrefersApiCallWillGetFeaturesFromApiAndRefreshCache() | ||
{ | ||
_config.PreferServerSentEvents = false; | ||
|
||
_cache | ||
.Setup(x => x.RefreshWith(It.IsAny<IDictionary<string, Feature>>(), It.IsAny<CancellationToken?>())) | ||
.Returns(Task.CompletedTask) | ||
.Verifiable(); | ||
|
||
var features = await _worker.RefreshCacheFromApi(); | ||
|
||
features.Should().BeEquivalentTo(_availableFeatures); | ||
|
||
Mock.Verify(_cache); | ||
} | ||
|
||
[Fact] | ||
public async Task HttpResponseWithServerSentEventSupportWillStartBackgroundListenerIfPreferred() | ||
{ | ||
_config.PreferServerSentEvents = true; | ||
_httpClientFactory.IsServerSentEventsEnabled = true; | ||
|
||
// We need to collect the cache attempts for comparison and verification. We also need to | ||
// make sure that the test method doesn't get ahead of the refresh attempts so we're | ||
// adding in a reset event that will be triggered on every cache refresh to let the test | ||
// incrementally move forward when it's appropriate. | ||
|
||
var cachedResults = new ConcurrentQueue<IDictionary<string, Feature>>(); | ||
var resetEvent = new AutoResetEvent(false); | ||
|
||
_cache | ||
.Setup(x => x.RefreshWith(It.IsAny<IDictionary<string, Feature>>(), It.IsAny<CancellationToken?>())) | ||
.Callback((IDictionary<string, Feature> x, CancellationToken? _) => | ||
{ | ||
cachedResults.Enqueue(x); | ||
resetEvent.Set(); | ||
}) | ||
.Returns(Task.CompletedTask); | ||
|
||
var features = await _worker.RefreshCacheFromApi(); | ||
|
||
resetEvent.WaitOne(5000).Should().BeTrue("because the cache should be refreshed within 5 seconds"); | ||
resetEvent.WaitOne(5000).Should().BeTrue("because the cache should be refreshed again within 5 seconds"); | ||
|
||
_worker.Cancel(); | ||
|
||
cachedResults.Count.Should().Be(2, "because the initial API call refreshed the cache once and the server sent listener refreshed it a second time"); | ||
cachedResults.TryDequeue(out var first); | ||
cachedResults.TryDequeue(out var second); | ||
first.Should().BeEquivalentTo(_httpClientFactory.ResponseContent, "because those are the features returned from the initial API call"); | ||
second.Should().BeEquivalentTo(_httpClientFactory.StreamResponseContent, "because those are the features returned from the server sent events API call"); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,133 @@ | ||
using System; | ||
using System.Collections.Generic; | ||
using System.Linq; | ||
using System.Text; | ||
using System.Threading; | ||
using System.Threading.Tasks; | ||
using GrowthBook.Api; | ||
using Microsoft.Extensions.Logging; | ||
using Moq; | ||
using Xunit; | ||
|
||
namespace GrowthBook.Tests.ApiTests; | ||
|
||
public class FeatureRepositoryTests : ApiUnitTest<FeatureRepository> | ||
{ | ||
private readonly Mock<IGrowthBookFeatureRefreshWorker> _backgroundWorker; | ||
private readonly FeatureRepository _featureRepository; | ||
|
||
public FeatureRepositoryTests() | ||
{ | ||
_backgroundWorker = StrictMockOf<IGrowthBookFeatureRefreshWorker>(); | ||
_featureRepository = new(_logger, _cache.Object, _backgroundWorker.Object); | ||
} | ||
|
||
[Fact] | ||
public void CancellingRepositoryWillCancelBackgroundWorker() | ||
{ | ||
_backgroundWorker | ||
.Setup(x => x.Cancel()) | ||
.Verifiable(); | ||
|
||
_featureRepository.Cancel(); | ||
|
||
_backgroundWorker.Verify(x => x.Cancel(), Times.Once, "Cancelling the background worker did not succeed"); | ||
} | ||
|
||
[Theory] | ||
[InlineData(false, null)] | ||
[InlineData(false, false)] | ||
public async Task GettingFeaturesWhenApiCallIsUnnecessaryWillGetFromCache(bool isCacheExpired, bool? isForcedRefresh) | ||
{ | ||
_cache | ||
.SetupGet(x => x.IsCacheExpired) | ||
.Returns(isCacheExpired) | ||
.Verifiable(); | ||
|
||
_cache | ||
.Setup(x => x.GetFeatures(It.IsAny<CancellationToken?>())) | ||
.ReturnsAsync(_availableFeatures) | ||
.Verifiable(); | ||
|
||
var options = isForcedRefresh switch | ||
{ | ||
null => null, | ||
_ => new GrowthBookRetrievalOptions { ForceRefresh = isForcedRefresh.Value } | ||
}; | ||
|
||
var features = await _featureRepository.GetFeatures(options); | ||
|
||
Mock.Verify(_cache); | ||
} | ||
|
||
[Theory] | ||
[InlineData(false, true)] | ||
[InlineData(true, null)] | ||
[InlineData(true, false)] | ||
[InlineData(true, true)] | ||
public async Task GettingFeaturesWhenApiCallIsRequiredWithoutWaitingForRetrievalWillGetFromCache(bool isCacheExpired, bool? isForcedRefresh) | ||
{ | ||
_cache | ||
.SetupGet(x => x.IsCacheExpired) | ||
.Returns(isCacheExpired) | ||
.Verifiable(); | ||
|
||
_cache | ||
.SetupGet(x => x.FeatureCount) | ||
.Returns(_availableFeatures.Count) | ||
.Verifiable(); | ||
|
||
_cache | ||
.Setup(x => x.GetFeatures(It.IsAny<CancellationToken?>())) | ||
.ReturnsAsync(_availableFeatures) | ||
.Verifiable(); | ||
|
||
_backgroundWorker | ||
.Setup(x => x.RefreshCacheFromApi(It.IsAny<CancellationToken?>())) | ||
.ReturnsAsync(_availableFeatures) | ||
.Verifiable(); | ||
|
||
var options = isForcedRefresh switch | ||
{ | ||
null => null, | ||
_ => new GrowthBookRetrievalOptions { ForceRefresh = isForcedRefresh.Value } | ||
}; | ||
|
||
var features = await _featureRepository.GetFeatures(options); | ||
|
||
Mock.Verify(_cache, _backgroundWorker); | ||
} | ||
|
||
[Theory] | ||
[InlineData(false, true)] | ||
[InlineData(true, null)] | ||
[InlineData(true, false)] | ||
[InlineData(true, true)] | ||
public async Task GettingFeaturesWhenApiCallIsRequiredWithWaitingForRetrievalWillGetFromApiCallInsteadOfCache(bool isCacheEmpty, bool? isForcedWait) | ||
{ | ||
_cache | ||
.SetupGet(x => x.IsCacheExpired) | ||
.Returns(true) | ||
.Verifiable(); | ||
|
||
_cache | ||
.SetupGet(x => x.FeatureCount) | ||
.Returns(isCacheEmpty ? 0 : 1) | ||
.Verifiable(); | ||
|
||
_backgroundWorker | ||
.Setup(x => x.RefreshCacheFromApi(It.IsAny<CancellationToken?>())) | ||
.ReturnsAsync(_availableFeatures) | ||
.Verifiable(); | ||
|
||
var options = isForcedWait switch | ||
{ | ||
null => null, | ||
_ => new GrowthBookRetrievalOptions { WaitForCompletion = isForcedWait.Value } | ||
}; | ||
|
||
var features = await _featureRepository.GetFeatures(options); | ||
|
||
Mock.Verify(_cache, _backgroundWorker); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
use the new keyword to get rid of the "hiding" errors
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Whoops, forgot to remove that from the base class. Thanks!