Skip to content

Commit

Permalink
Merge pull request #139 from martincostello/Test-Lambda-Server
Browse files Browse the repository at this point in the history
Add Lambda test server for integration tests
  • Loading branch information
martincostello committed Nov 2, 2019
2 parents d13063b + 38af6d3 commit 8a9e37d
Show file tree
Hide file tree
Showing 12 changed files with 1,196 additions and 20 deletions.
52 changes: 39 additions & 13 deletions src/LondonTravel.Skill/Function.cs
@@ -1,11 +1,12 @@
// Copyright (c) Martin Costello, 2017. All rights reserved.
// Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information.

using System;
using System.Net.Http;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Alexa.NET.Request;
using Alexa.NET.Response;
using Amazon.Lambda.Core;
using Amazon.Lambda.RuntimeSupport;
using Amazon.Lambda.Serialization.Json;

Expand All @@ -14,29 +15,54 @@ namespace MartinCostello.LondonTravel.Skill
/// <summary>
/// A class representing the entry-point to a custom AWS Lambda runtime. This class cannot be inherited.
/// </summary>
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
/// <remarks>
/// See https://aws.amazon.com/blogs/developer/announcing-amazon-lambda-runtimesupport/.
/// </remarks>
internal static class Function
{
/// <summary>
/// The main entry point for the custom runtime.
/// Runs the function using a custom runtime as an asynchronous operation.
/// </summary>
/// <param name="httpClient">The optional HTTP client to use.</param>
/// <param name="cancellationToken">The optional cancellation token to use.</param>
/// <returns>
/// A <see cref="Task"/> representing the asynchronous operation to run the custom runtime.
/// A <see cref="Task"/> representing the asynchronous operation to run the function.
/// </returns>
/// <remarks>
/// See https://aws.amazon.com/blogs/developer/announcing-amazon-lambda-runtimesupport/.
/// </remarks>
internal static async Task Main()
internal static async Task RunAsync(
HttpClient httpClient = null,
CancellationToken cancellationToken = default)
{
var serializer = new JsonSerializer();
var function = new AlexaFunction();

Func<SkillRequest, ILambdaContext, Task<SkillResponse>> handler = function.HandlerAsync;

using var handlerWrapper = HandlerWrapper.GetHandlerWrapper(handler, serializer);
using var handlerWrapper = HandlerWrapper.GetHandlerWrapper<SkillRequest, SkillResponse>(function.HandlerAsync, serializer);
using var bootstrap = new LambdaBootstrap(handlerWrapper);

await bootstrap.RunAsync();
if (httpClient != null)
{
SetHttpClient(bootstrap, httpClient);
}

await bootstrap.RunAsync(cancellationToken);
}

/// <summary>
/// The main entry point for the custom runtime.
/// </summary>
/// <returns>
/// A <see cref="Task"/> representing the asynchronous operation to run the custom runtime.
/// </returns>
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
private static async Task Main() => await RunAsync();

private static void SetHttpClient(LambdaBootstrap bootstrap, HttpClient httpClient)
{
// Replace the internal runtime API client with one using the specified HttpClient.
// See https://github.com/aws/aws-lambda-dotnet/blob/4f9142b95b376bd238bce6be43f4e1ec1f983592/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs#L41
var client = new RuntimeApiClient(httpClient);

var property = typeof(LambdaBootstrap).GetProperty("Client", BindingFlags.Instance | BindingFlags.NonPublic);
property.SetValue(bootstrap, client);
}
}
}
6 changes: 6 additions & 0 deletions src/LondonTravel.Skill/InternalsVisibleTo.cs
@@ -0,0 +1,6 @@
// Copyright (c) Martin Costello, 2017. All rights reserved.
// Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information.

using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("LondonTravel.Skill.Tests")]
90 changes: 90 additions & 0 deletions test/LondonTravel.Skill.Tests/EndToEndTests.cs
@@ -0,0 +1,90 @@
// Copyright (c) Martin Costello, 2017. All rights reserved.
// Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information.

using System;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Alexa.NET.Request;
using Alexa.NET.Request.Type;
using Alexa.NET.Response;
using MartinCostello.LondonTravel.Skill.Integration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Shouldly;
using Xunit;
using Xunit.Abstractions;

namespace MartinCostello.LondonTravel.Skill
{
public class EndToEndTests : FunctionTests
{
public EndToEndTests(ITestOutputHelper outputHelper)
: base(outputHelper)
{
}

[Fact]
public async Task Alexa_Function_Can_Process_Request()
{
// Arrange
SkillRequest request = CreateRequest<LaunchRequest>();
request.Request.Type = "LaunchRequest";

string json = JsonConvert.SerializeObject(request);

void Configure(IServiceCollection services)
{
services.AddLogging((builder) => builder.AddXUnit(this));
}

using var server = new LambdaTestServer(Configure);
using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(2));

await server.StartAsync(cancellationTokenSource.Token);

ChannelReader<LambdaResponse> reader = await server.EnqueueAsync(json);

// Queue a task to stop the Lambda function as soon as the response is processed
_ = Task.Run(async () =>
{
await reader.WaitToReadAsync(cancellationTokenSource.Token);
if (!cancellationTokenSource.IsCancellationRequested)
{
cancellationTokenSource.Cancel();
}
});

using var httpClient = server.CreateClient();

// Act
await Function.RunAsync(httpClient, cancellationTokenSource.Token);

// Assert
reader.TryRead(out LambdaResponse result).ShouldBeTrue();

result.ShouldNotBeNull();
result.IsSuccessful.ShouldBeTrue();
result.Content.ShouldNotBeNull();
result.Content.ShouldNotBeEmpty();

json = System.Text.Encoding.UTF8.GetString(result.Content);
var actual = JsonConvert.DeserializeObject<SkillResponse>(json);

actual.ShouldNotBeNull();

ResponseBody response = AssertResponse(actual, shouldEndSession: false);

response.Card.ShouldBeNull();
response.Reprompt.ShouldBeNull();

response.OutputSpeech.ShouldNotBeNull();
response.OutputSpeech.Type.ShouldBe("SSML");

var ssml = response.OutputSpeech.ShouldBeOfType<SsmlOutputSpeech>();
ssml.Ssml.ShouldBe("<speak>Welcome to London Travel. You can ask me about disruption or for the status of any tube line, London Overground, the D.L.R. or T.F.L. Rail.</speak>");
}
}
}
45 changes: 38 additions & 7 deletions test/LondonTravel.Skill.Tests/FunctionTests.cs
@@ -1,6 +1,7 @@
// Copyright (c) Martin Costello, 2017. All rights reserved.
// Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information.

using System;
using System.Collections.Generic;
using Alexa.NET.Request;
using Alexa.NET.Request.Type;
Expand All @@ -17,17 +18,17 @@

namespace MartinCostello.LondonTravel.Skill
{
public abstract class FunctionTests
public abstract class FunctionTests : ITestOutputHelperAccessor
{
protected FunctionTests(ITestOutputHelper outputHelper)
{
OutputHelper = outputHelper;
Interceptor = new HttpClientInterceptorOptions().ThrowsOnMissingRegistration();
}

protected HttpClientInterceptorOptions Interceptor { get; }
public ITestOutputHelper OutputHelper { get; set; }

private ITestOutputHelper OutputHelper { get; }
protected HttpClientInterceptorOptions Interceptor { get; }

protected virtual ResponseBody AssertResponse(SkillResponse actual, bool? shouldEndSession = true)
{
Expand Down Expand Up @@ -93,16 +94,46 @@ protected virtual SkillRequest CreateIntentRequest(string name, params Slot[] sl
protected virtual SkillRequest CreateRequest<T>(T request = null)
where T : Request, new()
{
var application = new Application()
{
ApplicationId = "my-skill-id",
};

var user = new User()
{
UserId = Guid.NewGuid().ToString(),
};

var result = new SkillRequest()
{
Request = request ?? new T(),
Session = new Session()
Context = new Context()
{
Application = new Application()
AudioPlayer = new PlaybackState()
{
PlayerActivity = "IDLE",
},
System = new AlexaSystem()
{
ApplicationId = "my-skill-id",
Application = application,
Device = new Device()
{
SupportedInterfaces = new Dictionary<string, object>()
{
["AudioPlayer"] = new object(),
},
},
User = user,
},
},
Request = request ?? new T(),
Session = new Session()
{
Application = application,
Attributes = new Dictionary<string, object>(),
New = true,
User = user,
},
Version = "1.0",
};

result.Request.Locale = "en-GB";
Expand Down
49 changes: 49 additions & 0 deletions test/LondonTravel.Skill.Tests/Integration/LambdaRequest.cs
@@ -0,0 +1,49 @@
// Copyright (c) Martin Costello, 2017. All rights reserved.
// Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information.

using System;

namespace MartinCostello.LondonTravel.Skill.Integration
{
/// <summary>
/// A class representing a request to an AWS Lambda function.
/// </summary>
public class LambdaRequest
{
/// <summary>
/// Initializes a new instance of the <see cref="LambdaRequest"/> class.
/// </summary>
/// <param name="content">The raw content of the request to invoke the Lambda function with.</param>
/// <param name="awsRequestId">The optional AWS request Id to invoke the Lambda function with.</param>
/// <exception cref="ArgumentNullException">
/// <paramref name="content"/> is <see langword="null"/>.
/// </exception>
public LambdaRequest(byte[] content, string awsRequestId = null)
{
Content = content ?? throw new ArgumentNullException(nameof(content));
AwsRequestId = awsRequestId ?? Guid.NewGuid().ToString();
}

/// <summary>
/// Gets the AWS request Id for the request to the function.
/// </summary>
public string AwsRequestId { get; }

/// <summary>
/// Gets the raw byte content of the request to the function.
/// </summary>
public byte[] Content { get; }

/// <summary>
/// Gets or sets an optional string containing the serialized JSON
/// for the client context when invoked through the AWS Mobile SDK.
/// </summary>
public string ClientContext { get; set; }

/// <summary>
/// Gets or sets an optional string containing the serialized JSON for the
/// Amazon Cognito identity provider when invoked through the AWS Mobile SDK.
/// </summary>
public string CognitoIdentity { get; set; }
}
}
32 changes: 32 additions & 0 deletions test/LondonTravel.Skill.Tests/Integration/LambdaResponse.cs
@@ -0,0 +1,32 @@
// Copyright (c) Martin Costello, 2017. All rights reserved.
// Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information.

namespace MartinCostello.LondonTravel.Skill.Integration
{
/// <summary>
/// A class representing a response from an AWS Lambda function. This class cannot be inherited.
/// </summary>
public sealed class LambdaResponse
{
/// <summary>
/// Initializes a new instance of the <see cref="LambdaResponse"/> class.
/// </summary>
/// <param name="content">The raw content of the response from the Lambda function.</param>
/// <param name="isSuccessful">Whether the response indicates the request was successfully handled.</param>
internal LambdaResponse(byte[] content, bool isSuccessful)
{
Content = content;
IsSuccessful = isSuccessful;
}

/// <summary>
/// Gets the raw byte content of the response from the function.
/// </summary>
public byte[] Content { get; }

/// <summary>
/// Gets a value indicating whether the response indicates the request was successfully handled.
/// </summary>
public bool IsSuccessful { get; }
}
}

0 comments on commit 8a9e37d

Please sign in to comment.