Skip to content

Commit

Permalink
Increase code coverage through discovery (#398)
Browse files Browse the repository at this point in the history
* Update IMM

* WIP - Multimode function test

* Pass configuration in in-process function tests

* Make TenancyAPIFeature tests(InProcessEmulateFunction...) pass

We've introduced a TenancyResponse type to wrap around HttpResponse so that we can test in different function enviroments

* Add separate Bindings class for DirectInvocation

To test direct invocation we need to add configuration independent of `ClientBindings`.

* WIP DirectInvocation tests for tenancyApiFeature

* Fix remaining direct invocation tests

* Change IConfiguration to IConfigurationRoot

* Avoid specifying well known child tenant Id

We get a 409 from Azure Storage because it thinks we're trying to create a container with the same name as one we just deleted. This is because the side effect of specifying a tenant Id causes a container of the same name to be created in Azure Blob Storage.

* Address nullability warnings

I've made etag parameter for `TenancyService.GetTenantAsync` nullable as the method that refers to it (tenantStore.GetTenantAsync`) also has a nullable etag parameter

* Exclude files from code coverage

* Update Endjin.RecommendedPractices.Build version

* remove unused feature tag

Co-authored-by: Ian Griffiths <Ian.Griffiths@endjin.com>
Co-authored-by: Ian Griffiths <ian@interact-sw.co.uk>
  • Loading branch information
3 people authored Jan 27, 2022
1 parent b380fed commit 788571a
Show file tree
Hide file tree
Showing 21 changed files with 1,421 additions and 148 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ public async Task<OpenApiResult> GetChildrenAsync(
public async Task<OpenApiResult> GetTenantAsync(
string tenantId,
[OpenApiParameter("If-None-Match")]
string etag,
string? etag,
IOpenApiContext context)
{
if (string.IsNullOrEmpty(tenantId))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,29 @@
namespace Marain.Tenancy.Specs.Integration.Bindings
{
using System;
using System.Linq;
using System.Threading.Tasks;

using BoDi;

using Corvus.Extensions.Json;
using Corvus.Testing.AzureFunctions;
using Corvus.Testing.AzureFunctions.SpecFlow;
using Corvus.Testing.SpecFlow;

using Marain.Tenancy.OpenApi;
using Marain.Tenancy.Specs.MultiHost;
using Menes;
using Menes.Hal;
using Menes.Internal;
using Menes.Testing.AspNetCoreSelfHosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

using NUnit.Framework.Internal;

using TechTalk.SpecFlow;

/// <summary>
Expand All @@ -22,32 +36,73 @@ namespace Marain.Tenancy.Specs.Integration.Bindings
[Binding]
public static class FunctionBindings
{
private static readonly string TenancyApiBaseUriText = $"http://localhost:{TenancyApiPort}";

public const int TenancyApiPort = 7071;

public static readonly Uri TenancyApiBaseUri = new ($"http://localhost:{TenancyApiPort}");
public static readonly Uri TenancyApiBaseUri = new (TenancyApiBaseUriText);

public static TestHostModes TestHostMode => TestExecutionContext.CurrentContext.TestObject switch
{
IMultiModeTest<TestHostModes> multiModeTest => multiModeTest.TestType,
_ => TestHostModes.UseFunctionHost,
};

/// <summary>
/// Runs the public API function.
/// </summary>
/// <param name="featureContext">The current feature context.</param>
/// <returns>A task that completes when the functions have been started.</returns>
[BeforeFeature("useTenancyFunction", Order = ContainerBeforeFeatureOrder.ServiceProviderAvailable)]
public static Task RunPublicApiFunction(FeatureContext featureContext)
public static async Task RunPublicApiFunction(
FeatureContext featureContext,
IObjectContainer specFlowDiContainer)
{
FunctionsController functionsController = FunctionsBindings.GetFunctionsController(featureContext);
FunctionConfiguration functionsConfig = FunctionsBindings.GetFunctionConfiguration(featureContext);
IConfiguration config = ContainerBindings.GetServiceProvider(featureContext).GetRequiredService<IConfiguration>();
IServiceProvider serviceProvider = ContainerBindings.GetServiceProvider(featureContext);

switch (TestHostMode)
{
case TestHostModes.InProcessEmulateFunctionWithActionResult:
var hostManager = new OpenApiWebHostManager();
featureContext.Set(hostManager);
await hostManager.StartInProcessFunctionsHostAsync<FunctionsStartupWrapper>(
TenancyApiBaseUriText,
config);
break;

case TestHostModes.UseFunctionHost:
FunctionsController functionsController = FunctionsBindings.GetFunctionsController(featureContext);
FunctionConfiguration functionsConfig = FunctionsBindings.GetFunctionConfiguration(featureContext);

functionsConfig.CopyToEnvironmentVariables(config.AsEnumerable());
functionsConfig.EnvironmentVariables.Add("TenantCacheConfiguration__GetTenantResponseCacheControlHeaderValue", "max-age=300");

await functionsController.StartFunctionsInstance(
"Marain.Tenancy.Host.Functions",
TenancyApiPort,
"net6.0",
"csharp",
functionsConfig);
break;

case TestHostModes.DirectInvocation:
// Doing this for the side effects only - it causes the OpenApi document
// to be registered in Menes.
serviceProvider.GetRequiredService<IOpenApiHost<HttpRequest, IActionResult>>();
break;
}

IConfigurationRoot config = ContainerBindings.GetServiceProvider(featureContext).GetRequiredService<IConfigurationRoot>();

functionsConfig.CopyToEnvironmentVariables(config.AsEnumerable());
functionsConfig.EnvironmentVariables.Add("TenantCacheConfiguration__GetTenantResponseCacheControlHeaderValue", "max-age=300");
ITestableTenancyService serviceWrapper = TestHostMode == TestHostModes.DirectInvocation
? new DirectTestableTenancyService(
serviceProvider.GetRequiredService<TenancyService>(),
serviceProvider.GetRequiredService<SimpleOpenApiContext>())
: new ClientTestableTenancyService(
TenancyApiBaseUriText,
serviceProvider.GetRequiredService<IJsonSerializerSettingsProvider>().Instance);

return functionsController.StartFunctionsInstance(
"Marain.Tenancy.Host.Functions",
TenancyApiPort,
"net6.0",
"csharp",
functionsConfig);
specFlowDiContainer.RegisterInstanceAs(serviceWrapper);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,28 +33,31 @@ public static void SetupFeature(FeatureContext featureContext)
featureContext,
serviceCollection =>
{
var configData = new Dictionary<string, string>
if (FunctionBindings.TestHostMode != MultiHost.TestHostModes.DirectInvocation)
{
{ "TenancyServiceBaseUri", "http://localhost:7071" },
};
IConfigurationRoot config = new ConfigurationBuilder()
.AddInMemoryCollection(configData)
.AddEnvironmentVariables()
.AddJsonFile("local.settings.json", true, true)
.Build();
serviceCollection.AddSingleton(config);
var configData = new Dictionary<string, string>
{
{ "TenancyServiceBaseUri", "http://localhost:7071" },
};
IConfiguration config = new ConfigurationBuilder()
.AddInMemoryCollection(configData)
.AddEnvironmentVariables()
.AddJsonFile("local.settings.json", true, true)
.Build();
serviceCollection.AddSingleton(config);

serviceCollection.AddJsonNetSerializerSettingsProvider();
serviceCollection.AddJsonNetPropertyBag();
serviceCollection.AddJsonNetCultureInfoConverter();
serviceCollection.AddJsonNetDateTimeOffsetToIso8601AndUnixTimeConverter();
serviceCollection.AddSingleton<JsonConverter>(new StringEnumConverter(new CamelCaseNamingStrategy()));
serviceCollection.AddJsonNetSerializerSettingsProvider();
serviceCollection.AddJsonNetPropertyBag();
serviceCollection.AddJsonNetCultureInfoConverter();
serviceCollection.AddJsonNetDateTimeOffsetToIso8601AndUnixTimeConverter();
serviceCollection.AddSingleton<JsonConverter>(new StringEnumConverter(new CamelCaseNamingStrategy()));

serviceCollection.AddSingleton(sp => sp.GetRequiredService<IConfigurationRoot>().Get<TenancyClientOptions>());
serviceCollection.AddSingleton(sp => sp.GetRequiredService<IConfiguration>().Get<TenancyClientOptions>());

bool enableCaching = !featureContext.FeatureInfo.Tags.Contains("disableTenantCaching");
bool enableCaching = !featureContext.FeatureInfo.Tags.Contains("disableTenantCaching");

serviceCollection.AddTenantProviderServiceClient(enableCaching);
serviceCollection.AddTenantProviderServiceClient(enableCaching);
}
});
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// <copyright file="TenancyClientBindings.cs" company="Endjin Limited">
// Copyright (c) Endjin Limited. All rights reserved.
// </copyright>

namespace Marain.Tenancy.Specs.Integration.Bindings
{
using System;
using System.Collections.Generic;
using System.Linq;
using Corvus.Storage.Azure.BlobStorage;
using Corvus.Testing.SpecFlow;
using Marain.Tenancy.Client;
using Menes;
using Menes.Internal;
using Menes.Links;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Serialization;

using TechTalk.SpecFlow;

/// <summary>
/// Bindings for the integration tests for <see cref="TenancyService"/>.
/// </summary>
[Binding]
public static class TenancyServiceBindings
{
/// <summary>
/// Configures the DI container before tests start.
/// </summary>
/// <param name="featureContext">The SpecFlow test context.</param>
///
[BeforeFeature("withTenancyClient", Order = ContainerBeforeFeatureOrder.PopulateServiceCollection)]
public static void SetupFeature(FeatureContext featureContext)
{
ContainerBindings.ConfigureServices(
featureContext,
serviceCollection =>
{
if (FunctionBindings.TestHostMode == MultiHost.TestHostModes.DirectInvocation)
{
IConfiguration config = new ConfigurationBuilder()
.AddEnvironmentVariables()
.AddJsonFile("local.settings.json", true, true)
.Build();
serviceCollection.AddSingleton(config);

serviceCollection.AddSingleton(sp => sp.GetRequiredService<IConfiguration>().Get<TenancyClientOptions>());

BlobContainerConfiguration rootStorageConfiguration = config
.GetSection("RootBlobStorageConfiguration")
.Get<BlobContainerConfiguration>();

serviceCollection.AddTenantStoreOnAzureBlobStorage(rootStorageConfiguration);

serviceCollection.AddSingleton<SimpleOpenApiContext>();
serviceCollection.AddTenancyApiWithOpenApiActionResultHosting(ConfigureOpenApiHost);
}
});
}

private static void ConfigureOpenApiHost(IOpenApiHostConfiguration config)
{
if (config == null)
{
throw new ArgumentNullException(nameof(config), "AddTenancyApi callback: config");
}

if (config.Documents == null)
{
throw new ArgumentNullException(nameof(config.Documents), "AddTenancyApi callback: config.Documents");
}

config.Documents.AddSwaggerEndpoint();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ namespace Marain.Tenancy.Specs.Integration.Bindings
using System.Net.Http;
using System.Threading.Tasks;

using Corvus.Testing.SpecFlow;

using Marain.Tenancy.OpenApi;

using Menes;

using Microsoft.Extensions.DependencyInjection;

using TechTalk.SpecFlow;

[Binding]
Expand All @@ -29,16 +37,31 @@ public void AddWellKnownTenantToDelete(string parentId, string id)
}

[AfterScenario("@useTenancyFunction")]
public async Task CleanUpTestTenants()
public async Task CleanUpTestTenants(FeatureContext featureContext)
{
IServiceProvider serviceProvider = ContainerBindings.GetServiceProvider(featureContext);
var errors = new List<Exception>();
foreach ((string parentId, string id) in this.tenantsToDelete.OrderByDescending(t => t.ParentId.Length + t.TenantId.Length))
{
try
{
var deleteUri = new Uri(FunctionBindings.TenancyApiBaseUri, $"/{parentId}/marain/tenant/children/{id}");
HttpResponseMessage response = await HttpClient.DeleteAsync(deleteUri)
.ConfigureAwait(false);
if (FunctionBindings.TestHostMode != MultiHost.TestHostModes.DirectInvocation)
{
var deleteUri = new Uri(FunctionBindings.TenancyApiBaseUri, $"/{parentId}/marain/tenant/children/{id}");
HttpResponseMessage response = await HttpClient.DeleteAsync(deleteUri)
.ConfigureAwait(false);
}
else
{
// We were in direct mode, so there is no service with a delete endpoint
// we can hit. Instead, we need to invoke the service method.
TenancyService service = serviceProvider.GetRequiredService<TenancyService>();

await service.DeleteChildTenantAsync(
parentId,
id,
serviceProvider.GetRequiredService<SimpleOpenApiContext>());
}
}
catch (Exception x)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ Scenario: Create a tenant
| ParentTenantId | Name |
| f26450ab1668784bb327951c8b08f347 | Test |
Then I receive a 'Created' response
And the response should contain a 'Location' header
And the response should contain a Location header

Scenario: Get the root tenant
When I request the tenant with Id 'f26450ab1668784bb327951c8b08f347' from the API
Then I receive an 'OK' response
And the response content should have a string property called 'name' with value 'Root'
And the response should not contain an 'Etag' header
And the response should contain a 'Cache-Control' header with value 'max-age=300'
And the response should not contain an Etag header
And the response should contain a Cache-Control header with value 'max-age=300'

Scenario: Retrieve a newly created tenant using the location header returned from the create request
Given I have used the API to create a new tenant
Expand All @@ -36,15 +36,14 @@ Scenario: Retrieve a newly created tenant using the location header returned fro
When I request the tenant using the Location from the previous response
Then I receive an 'OK' response
And the response content should have a string property called 'name' with value 'Test'
And the response should contain an 'Etag' header
And the response should contain a 'Cache-Control' header with value 'max-age=300'
And the response should contain an Etag header
And the response should contain a Cache-Control header with value 'max-age=300'

Scenario: Request a tenant using the etag from a previous request
Scenario: Request a tenant using the etag from a previous get tenant request
Given I have used the API to create a new tenant
| ParentTenantId | Name |
| f26450ab1668784bb327951c8b08f347 | Test |
And I store the value of the response Location header as 'New tenant location'
And I have requested the tenant using the path called 'New tenant location'
When I request the tenant using the path called 'New tenant location' and the Etag from the previous response
And I store the id from the response Location header as 'New tenant ID'
And I have requested the tenant with the ID called 'New tenant ID'
When I request the tenant using the ID called 'New tenant ID' and the Etag from the previous response
Then I receive a 'NotModified' response

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Marain.Tenancy.Specs.MultiHost;

namespace Marain.Tenancy.Specs.Integration.Features
{
[MultiHostTest]

public partial class TenancyApiFeature : MultiTestHostBase
{
public TenancyApiFeature(TestHostModes hostMode)
: base(hostMode)
{
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ public void ThenTheResponseObjectShouldHaveAStringPropertyCalledWithValue(string
Assert.AreEqual(expectedValue, actualValue, $"Expected value of property '{propertyPath}' was '{expectedValue}', but actual value was '{actualValue}'");
}

public JObject? Json { get; internal set; }

[Then("the response content should have a boolean property called '(.*)' with value '(.*)'")]
public void ThenTheResponseContentShouldHaveABooleanPropertyCalledWithValue(string propertyPath, bool expectedValue)
{
Expand Down Expand Up @@ -103,7 +105,7 @@ public void ThenTheResponseContentShouldHaveAJsonPropertyCalledWithValue(string

public JToken GetRequiredTokenFromResponseObject(string propertyPath)
{
JObject data = this.ScenarioContext.Get<JObject>();
JObject data = this.Json ?? throw new InvalidOperationException("Json not present");
return GetRequiredToken(data, propertyPath);
}

Expand Down
Loading

0 comments on commit 788571a

Please sign in to comment.