Skip to content
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
merged 19 commits into from
Jan 29, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions GrowthBook.Tests/ApiTests/ApiUnitTest.cs
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
};
}
}
181 changes: 181 additions & 0 deletions GrowthBook.Tests/ApiTests/FeatureRefreshWorkerTests.cs
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

View workflow job for this annotation

GitHub Actions / build (3.1.x)

'FeatureRefreshWorkerTests._worker' hides inherited member 'ApiUnitTest<FeatureRefreshWorker>._worker'. Use the new keyword if hiding was intended.

Check warning on line 107 in GrowthBook.Tests/ApiTests/FeatureRefreshWorkerTests.cs

View workflow job for this annotation

GitHub Actions / build (6.0.x)

'FeatureRefreshWorkerTests._worker' hides inherited member 'ApiUnitTest<FeatureRefreshWorker>._worker'. Use the new keyword if hiding was intended.

Check warning on line 107 in GrowthBook.Tests/ApiTests/FeatureRefreshWorkerTests.cs

View workflow job for this annotation

GitHub Actions / build (7.0.x)

'FeatureRefreshWorkerTests._worker' hides inherited member 'ApiUnitTest<FeatureRefreshWorker>._worker'. Use the new keyword if hiding was intended.
Copy link
Contributor

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

Copy link
Collaborator Author

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!


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");
}
}
133 changes: 133 additions & 0 deletions GrowthBook.Tests/ApiTests/FeatureRepositoryTests.cs
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);
}
}
Loading