-
Notifications
You must be signed in to change notification settings - Fork 13
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
Merged
+5,027
−1,166
Merged
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
d8bb875
Updated data structure, added unimplemented functionality
Norhaven a1025e8
Finished spec methods/logic, added most standard tests and test frame…
Norhaven 41888d7
Added decryption/version compare and fixed last tests
Norhaven 12776f0
Refactored for clarity, fixed tests
Norhaven fafdf68
Reorganized for clarity, added initial API integration
Norhaven 93d8c7e
Added API unit tests, refactored to fix small issues
Norhaven 56e6e7c
Added XML docs, comments, logging where necessary
Norhaven 3c0caff
Minor refactoring
Norhaven 4bc4c0d
Updated project properties
Norhaven 9623452
Replaced usages of float with double, adjusted GetEqualWeights test
Norhaven 4cc1732
Made changes from PR feedback
Norhaven a182c6e
PR fixes
Norhaven b96558c
Various PR fixes
Norhaven 880b499
Changed to IList in data models
Norhaven f374527
Refactored to pull HTTP logic into extensions
Norhaven e5b2021
Fixed defaulting of feature repository
Norhaven a4cf071
Replaced Moq with NSubstitute
Norhaven d393950
Fixing brittleness of feature refresh worker test
Norhaven b6fb94c
Updated changelog
Norhaven File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
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 NSubstitute; | ||
|
||
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 IGrowthBookFeatureCache _cache; | ||
protected readonly Feature _firstFeature; | ||
protected readonly Feature _secondFeature; | ||
protected readonly Dictionary<string, Feature> _availableFeatures; | ||
|
||
public ApiUnitTest() | ||
{ | ||
_logger = Substitute.For<ILogger<T>>(); | ||
_cache = Substitute.For<IGrowthBookFeatureCache>(); | ||
|
||
_firstFeature = new() { DefaultValue = 1 }; | ||
_secondFeature = new() { DefaultValue = 2 }; | ||
_availableFeatures = new() | ||
{ | ||
[FirstFeatureId] = _firstFeature, | ||
[SecondFeatureId] = _secondFeature | ||
}; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,183 @@ | ||
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 Newtonsoft.Json; | ||
using NSubstitute; | ||
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(HttpHeaders.ServerSentEvents.Key, HttpHeaders.ServerSentEvents.EnabledValue); | ||
} | ||
|
||
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; | ||
|
||
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); | ||
} | ||
|
||
[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 | ||
.RefreshWith(Arg.Any<IDictionary<string, Feature>>(), Arg.Any<CancellationToken?>()) | ||
.Returns(Task.CompletedTask); | ||
|
||
var features = await _worker.RefreshCacheFromApi(); | ||
|
||
features.Should().BeEquivalentTo(_availableFeatures); | ||
|
||
await _cache.Received(1).RefreshWith(Arg.Any<IDictionary<string, Feature>>(), Arg.Any<CancellationToken?>()); | ||
} | ||
|
||
[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 ManualResetEventSlim(false); | ||
|
||
_cache | ||
.RefreshWith(Arg.Any<IDictionary<string, Feature>>(), Arg.Any<CancellationToken?>()) | ||
.Returns(Task.CompletedTask) | ||
.AndDoes(x => | ||
{ | ||
cachedResults.Enqueue((IDictionary<string, Feature>)x[0]); | ||
|
||
if (cachedResults.Count > 1) | ||
{ | ||
resetEvent.Set(); | ||
} | ||
}); | ||
|
||
var features = await _worker.RefreshCacheFromApi(); | ||
|
||
resetEvent.Wait(5000).Should().BeTrue("because the cache should be refreshed 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"); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,115 @@ | ||
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 NSubstitute; | ||
using Xunit; | ||
|
||
namespace GrowthBook.Tests.ApiTests; | ||
|
||
public class FeatureRepositoryTests : ApiUnitTest<FeatureRepository> | ||
{ | ||
private readonly IGrowthBookFeatureRefreshWorker _backgroundWorker; | ||
private readonly FeatureRepository _featureRepository; | ||
|
||
public FeatureRepositoryTests() | ||
{ | ||
_backgroundWorker = Substitute.For<IGrowthBookFeatureRefreshWorker>(); | ||
_featureRepository = new(_logger, _cache, _backgroundWorker); | ||
} | ||
|
||
[Fact] | ||
public void CancellingRepositoryWillCancelBackgroundWorker() | ||
{ | ||
_featureRepository.Cancel(); | ||
|
||
_backgroundWorker.Received(1).Cancel(); | ||
} | ||
|
||
[Theory] | ||
[InlineData(false, null)] | ||
[InlineData(false, false)] | ||
public async Task GettingFeaturesWhenApiCallIsUnnecessaryWillGetFromCache(bool isCacheExpired, bool? isForcedRefresh) | ||
{ | ||
_cache.IsCacheExpired.Returns(isCacheExpired); | ||
|
||
_cache | ||
.GetFeatures(Arg.Any<CancellationToken?>()) | ||
.Returns(_availableFeatures); | ||
|
||
var options = isForcedRefresh switch | ||
{ | ||
null => null, | ||
_ => new GrowthBookRetrievalOptions { ForceRefresh = isForcedRefresh.Value } | ||
}; | ||
|
||
var features = await _featureRepository.GetFeatures(options); | ||
|
||
await _cache.Received(1).GetFeatures(Arg.Any<CancellationToken?>()); | ||
} | ||
|
||
[Theory] | ||
[InlineData(false, true)] | ||
[InlineData(true, null)] | ||
[InlineData(true, false)] | ||
[InlineData(true, true)] | ||
public async Task GettingFeaturesWhenApiCallIsRequiredWithoutWaitingForRetrievalWillGetFromCache(bool isCacheExpired, bool? isForcedRefresh) | ||
{ | ||
_cache.IsCacheExpired.Returns(isCacheExpired); | ||
|
||
_cache.FeatureCount.Returns(_availableFeatures.Count); | ||
|
||
_cache | ||
.GetFeatures(Arg.Any<CancellationToken?>()) | ||
.Returns(_availableFeatures); | ||
|
||
_backgroundWorker | ||
.RefreshCacheFromApi(Arg.Any<CancellationToken?>()) | ||
.Returns(_availableFeatures); | ||
|
||
var options = isForcedRefresh switch | ||
{ | ||
null => null, | ||
_ => new GrowthBookRetrievalOptions { ForceRefresh = isForcedRefresh.Value } | ||
}; | ||
|
||
var features = await _featureRepository.GetFeatures(options); | ||
|
||
_ = _cache.Received(2).IsCacheExpired; | ||
_ = _cache.Received(1).FeatureCount; | ||
_ = _cache.Received(1).GetFeatures(Arg.Any<CancellationToken?>()); | ||
_ = _backgroundWorker.Received(1).RefreshCacheFromApi(Arg.Any<CancellationToken?>()); | ||
} | ||
|
||
[Theory] | ||
[InlineData(false, true)] | ||
[InlineData(true, null)] | ||
[InlineData(true, false)] | ||
[InlineData(true, true)] | ||
public async Task GettingFeaturesWhenApiCallIsRequiredWithWaitingForRetrievalWillGetFromApiCallInsteadOfCache(bool isCacheEmpty, bool? isForcedWait) | ||
{ | ||
_cache.IsCacheExpired.Returns(true); | ||
|
||
_cache.FeatureCount.Returns(isCacheEmpty ? 0 : 1); | ||
|
||
_backgroundWorker | ||
.RefreshCacheFromApi(Arg.Any<CancellationToken?>()) | ||
.Returns(_availableFeatures); | ||
|
||
var options = isForcedWait switch | ||
{ | ||
null => null, | ||
_ => new GrowthBookRetrievalOptions { WaitForCompletion = isForcedWait.Value } | ||
}; | ||
|
||
var features = await _featureRepository.GetFeatures(options); | ||
|
||
_ = _cache.Received(2).IsCacheExpired; | ||
_ = _cache.Received(2).FeatureCount; | ||
_ = _backgroundWorker.Received(1).RefreshCacheFromApi(Arg.Any<CancellationToken?>()); | ||
} | ||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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!